Skip to content

feat(webapp): consolidate auth path + add comprehensive auth tests#3499

Draft
matt-aitken wants to merge 61 commits intomainfrom
rbac-packages
Draft

feat(webapp): consolidate auth path + add comprehensive auth tests#3499
matt-aitken wants to merge 61 commits intomainfrom
rbac-packages

Conversation

@matt-aitken
Copy link
Copy Markdown
Member

Summary

Consolidates the webapp's authentication and authorization into a small set of route helpers, replacing the ad-hoc requireUser / requireUserId / authenticatedEnvironmentForAuthentication calls scattered across routes. Same security model, but the per-request flow (authenticate → authorize → load) now lives in one place per route family.

Adds a comprehensive end-to-end auth test suite that didn't exist before — 162 tests covering API key, PAT and JWT auth across the public API surface, plus dashboard session auth for admin pages.

Changes

Dashboard auth (started, partial rollout)

Admin and settings pages migrated to a unified loader/action helper that authenticates the session, runs an authorization check, and exposes the result to the route. Other dashboard routes still on the old pattern; remaining migration tracked separately.

Migrated routes:

  • admin.* (14 admin / back-office / feature-flags / LLM-models / notifications / orgs / concurrency pages)
  • _app.orgs.$organizationSlug.settings.team
  • _app.orgs.$organizationSlug.settings.roles

API / realtime / engine auth (complete for the migrated families)

71 routes migrated to a unified apiBuilder that centralizes Bearer / PAT / Public-JWT authentication and applies the per-route authorization check before the handler runs. Includes:

  • api.v1.* and api.v2.* and api.v3.* — tasks, runs, batches, queues, prompts, deployments, query, sessions, waitpoints, packets, workers, idempotency keys
  • realtime.v1.* — runs, batches, sessions, streams
  • engine.v1.* — dev / worker-action protocols

Side effect: action aliases preserved historic JWT scope semantics where the new model is stricter (e.g. a write:tasks JWT now also satisfies trigger / batchTrigger / update actions on the same resource — matched at the auth boundary, not in the route handler).

Auth test suite (new — *.e2e.full.test.ts)

162 e2e tests run against a real spawned webapp + Postgres (no mocks). Coverage matrix:

  • API key auth — read / write / trigger / batchTrigger / deploy actions across runs, batches, deployments, prompts, queues, query, sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key resources (a run carries batch / tag / task identifiers — auth must accept any matching scope)
  • Personal Access Token auth — comprehensive matrix: scope match, scope mismatch, missing scope, expired token, malformed token
  • Public JWT auth — sub-vs-URL environment resolution, expired JWTs, signature verification, scope checking, otu (one-time-use) token semantics, branch-environment signing-key fallback
  • Dashboard session auth — admin-only pages reject non-admins; per-action gating
  • Cross-cutting edge cases — revoked API key grace window, JWT cross-environment isolation, MissingResource branch behaviour

Test plan

  • pnpm run typecheck --filter webapp clean
  • pnpm exec vitest run --config apps/webapp/vitest.e2e.full.config.ts — 162/162 pass
  • Spot-check an authed API endpoint with a valid + invalid API key against a local stack
  • Spot-check the migrated admin pages render and gate non-admins

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 1, 2026

🦋 Changeset detected

Latest commit: 977089c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
@trigger.dev/plugins Minor
@trigger.dev/rbac Minor
@trigger.dev/build Minor
@trigger.dev/core Minor
@trigger.dev/python Minor
@trigger.dev/react-hooks Minor
@trigger.dev/redis-worker Minor
@trigger.dev/rsc Minor
@trigger.dev/schema-to-json Minor
@trigger.dev/sdk Minor
@trigger.dev/database Minor
@trigger.dev/otlp-importer Minor
trigger.dev Minor
d3-chat Patch
references-d3-openai-agents Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/zod-worker Patch
references-nextjs-realtime Patch
references-realtime-hooks-test Patch
references-realtime-streams Patch
@internal/sdk-compat-tests Patch
references-telemetry Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 1, 2026

Walkthrough

Adds a new internal RBAC package and plugin contracts, a fallback RBAC controller, and a lazy loader. Integrates RBAC into the webapp: new Roles settings routes/UI, invite role picker, PAT role selection, team role assignment, and role-presenter changes. Replaces many API/dashboard auth callsites to use RBAC-based builders and typed resource descriptors (removing legacy superScopes). Adds a Prisma migration for OrgMemberInvite.rbacRoleId, extensive e2e RBAC tests and helpers, CI workflow, and supporting service/util updates.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~150 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rbac-packages

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx (1)

362-380: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Gate pending-invite actions behind canManageMembers too.

After widening the page itself to read:members, the resend/revoke controls are still rendered for users who cannot manage members. That leaves dead UI at best, and it risks an auth gap if those sibling routes were previously relying on page-level gating.

Suggested change
-                        <div className="flex grow items-center justify-end gap-x-2">
-                          <ResendButton invite={invite} />
-                          <RevokeButton invite={invite} />
-                        </div>
+                        <div className="flex grow items-center justify-end gap-x-2">
+                          {canManageMembers ? (
+                            <>
+                              <ResendButton invite={invite} />
+                              <RevokeButton invite={invite} />
+                            </>
+                          ) : null}
+                        </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.team/route.tsx
around lines 362 - 380, The pending-invite action buttons (ResendButton and
RevokeButton) are still rendered even when the viewer only has read:members;
wrap or conditionally render those controls behind the canManageMembers flag so
only users with permission see them—e.g., guard the action container (the div
containing ResendButton and RevokeButton) or each button with {canManageMembers
&& ...} in the invites.map rendering to prevent dead UI and potential auth gaps.
🧹 Nitpick comments (3)
apps/webapp/test/helpers/seedTestRun.ts (1)

6-10: ⚡ Quick win

Use a type alias here instead of an interface.

This file is TypeScript, and the exported shape should follow the repo rule.

Suggested fix
-export interface SeededRun {
+export type SeededRun = {
   run: TaskRun;
   runFriendlyId: string; // `run_...`
   batchFriendlyId?: string; // `batch_...` when { withBatch: true }
-}
+};

As per coding guidelines, Use types over interfaces for TypeScript.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/test/helpers/seedTestRun.ts` around lines 6 - 10, Replace the
exported interface SeededRun with an exported type alias: define "export type
SeededRun = { run: TaskRun; runFriendlyId: string; batchFriendlyId?: string }"
so the shape remains identical but uses a type instead of an interface; update
any imports/uses referencing SeededRun if needed to ensure the symbol name
remains the same.
apps/webapp/test/helpers/seedTestSession.ts (1)

15-29: ⚡ Quick win

Centralize the test session contract.

SESSION_SECRET and the cookie shape are duplicated here and in internal-packages/testcontainers/src/webapp.ts, so a one-sided change will turn every dashboard auth test into a hard-to-diagnose auth failure. Pulling the shared test-session config into one exported constant would remove that drift point.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/test/helpers/seedTestSession.ts` around lines 15 - 29, The
SESSION_SECRET and cookie session shape are duplicated; centralize them by
extracting the shared config into a single exported constant (e.g.,
TEST_SESSION_SECRET and TEST_SESSION_STORAGE_CONFIG or a single TEST_SESSION
object) and import it here instead of redefining SESSION_SECRET and calling
createCookieSessionStorage locally; update uses of SESSION_SECRET and
sessionStorage in this file to consume the shared export and remove the local
duplicate definitions (referencing the symbols SESSION_SECRET, sessionStorage,
and createCookieSessionStorage to locate the current code to replace).
packages/plugins/src/rbac.ts (1)

81-88: ⚡ Quick win

Prefer exported type aliases over interfaces in this RBAC contract.

These new public contracts are all declared as interface, which goes against the repo’s TS convention. Switching them to type keeps the surface effectively the same here while aligning the package with the rest of the codebase.

As per coding guidelines, **/*.{ts,tsx}: Use types over interfaces for TypeScript.

Also applies to: 104-216

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/plugins/src/rbac.ts` around lines 81 - 88, Replace the exported
interface declarations with exported type aliases to follow the repo TypeScript
convention; specifically change RbacAbility (and the other exported interfaces
in this file between lines ~104-216) from "export interface X { ... }" to
"export type X = { ... }" while preserving the exact members and names (e.g.,
can, canSuper on RbacAbility and any RbacResource, RbacPolicy, etc.), keeping
the public contract identical except using type aliases instead of interfaces.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/e2e-webapp-auth-full.yml:
- Around line 19-35: The workflow's pull_request path filters omit shared auth
fixtures (e.g., apps/webapp/test/helpers/seedTestEnvironment.ts) so PRs that
change those helpers won't trigger the e2e-auth-full run; update the path filter
in .github/workflows/e2e-webapp-auth-full.yml to include that file and the
helpers directory (for example add
apps/webapp/test/helpers/seedTestEnvironment.ts and/or
apps/webapp/test/helpers/**) so changes to seedTestEnvironment and other shared
auth helpers (seedTestSession, sharedTestServer, etc.) will trigger the
workflow.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.invite/route.tsx:
- Around line 202-211: The error message is inconsistent with the authorization
check using isStrictlyBelow(inviterRole?.id ?? null, submittedRbacRoleId) which
rejects equal roles; update the JSON error string returned in that branch to
clearly state the stricter rule (e.g., "You can only invite members strictly
below your own role" or similar) so the text matches the behavior in the
conditional that uses isStrictlyBelow.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.roles/route.tsx:
- Around line 170-173: The "Create role" CTA is hidden for enterprise accounts
because CreateRoleUpsell is only rendered when !isEnterprise; update the NavBar
blocks (where PageTitle and CreateRoleUpsell are used) to ensure enterprise
users see a create-role control by either rendering the actual create button for
enterprise (e.g., use the existing CreateRoleButton or RoleCreateModal trigger)
when isEnterprise is true, or always render CreateRoleUpsell but switch it to
the real create-action for enterprise. Locate and change the two occurrences
(the NavBar block with PageTitle "Roles" and the similar block around the later
occurrence) to conditionally render the appropriate component based on
isEnterprise so enterprise plans have a visible create-role CTA.

In `@apps/webapp/app/routes/account.tokens/route.tsx`:
- Around line 68-90: The dropdown is built from allRoles.filter(r => r.isSystem)
but the default and server trust come from rbac.systemRoles(...).filter(r =>
r.available), causing a mismatch; update loadSystemRolesForUser to call
rbac.systemRoles(orgId) and filter by .available (e.g., systemRoles =
rbac.systemRoles(orgMember.organizationId).filter(r => r.available)) so the UI
only shows assignable system roles, and in the token-creation action (the POST
handler that reads roleId) revalidate that the submitted roleId exists in that
same available systemRoles set before creating the PAT (reject or error if not).
Apply the same change to the other similar blocks referenced (lines ~97-111 and
~141-165) so both the picker and server-side checks use
rbac.systemRoles(...).filter(r => r.available).

In `@apps/webapp/app/routes/admin.llm-models.missing._index.tsx`:
- Around line 33-35: Replace the ad-hoc parseInt logic for lookbackHours with
the existing SearchParams zod schema: use SearchParams.parse (or .safeParse) on
url.searchParams (or an object containing lookbackHours) and read the
validated/coerced lookbackHours from the result instead of parseInt; update the
loader code where lookbackHours is set (the const lookbackHours = ... line) to
use the parsed value so NaN cannot occur and downstream logic receives a
validated number.

In `@apps/webapp/app/routes/admin.llm-models.missing`.$model.tsx:
- Around line 23-25: The code reads lookbackHours via parseInt and may produce
NaN; replace that with a Zod coercion and bounds check: create a schema like
z.coerce.number().int().min(1).max(168).default(24) (or your desired max) and
call schema.parse on url.searchParams.get("lookbackHours") (or the raw value) to
produce a safe lookbackHours number; then use the validated lookbackHours
variable in the existing request handling and service call (refer to the
existing lookbackHours const and the URL = new URL(request.url) usage) so the
payload always receives a valid defaulted/bounded integer.

In `@apps/webapp/app/routes/admin.orgs.tsx`:
- Around line 36-38: Replace the thrown generic Error for invalid query params
with an explicit 400 client response: in the block that checks
searchParams.success (the if (!searchParams.success) branch), return a Response
(or Remix json/badRequest helper) with the searchParams.error (or a default
message) and status set to 400 instead of throwing; this ensures the handler in
routes/admin.orgs.tsx treats malformed/invalid query parameters as a client
error rather than a server exception.

In `@apps/webapp/app/routes/api.v2.tasks.batch.ts`:
- Around line 33-39: The batch authorization currently builds resources in the
authorization block for action "batchTrigger" using the resource closure and
relies on anyResource() which uses resource.some() (OR semantics); change the
check so the authorization requires AND semantics across the batch by ensuring
every task resource is authorized: update the resource-building/authorization
call referenced in the route (the authorization object for action "batchTrigger"
that maps body.items to resources) to either use the all-match helper (e.g., an
allResource/allResources variant) or adjust the logic to perform
resource.every(...) instead of resource.some(), so that all provided tasks must
be authorized before allowing the batch trigger; refer to
internal-packages/rbac/src/ability.ts (replace anyResource()/some with an
all-match implementation) and the authorization block in
routes/api.v2.tasks.batch.ts for the change.

In `@apps/webapp/app/routes/realtime.v1.streams`.$runId.$streamId.ts:
- Around line 100-110: The resource builder now reads run.taskIdentifier,
run.runTags and downstream code uses run.realtimeStreamsVersion but findResource
currently only returns id, friendlyId and batch; update the data retrieval in
findResource so the returned run object includes taskIdentifier, runTags (or
run_tags as stored) and realtimeStreamsVersion (or its DB field) in addition to
id/friendlyId/batch so the RBAC check and realtime stream setup receive those
fields; locate the findResource implementation and extend its SELECT/returned
fields to include these properties referenced in the resource function and later
handlers.

In `@apps/webapp/app/services/projectCreated.server.ts`:
- Around line 24-33: The check for staging entitlement accesses nested
subscription fields that may be undefined; update the guard around
getCurrentPlan()'s result so you only check hasStagingEnvironment when
v3Subscription, v3Subscription.plan and plan.limits exist (e.g., use a safe
optional chain or explicit null checks on plan.v3Subscription and
plan.v3Subscription.plan before reading
plan.v3Subscription.plan.limits.hasStagingEnvironment) and then call
createEnvironment(...) for STAGING and PREVIEW only when that guarded condition
is true; references: getCurrentPlan, plan, v3Subscription, createEnvironment.

In `@apps/webapp/test/auth-cross-cutting.e2e.full.test.ts`:
- Around line 206-213: The test currently allows either 401 or 404 for the
cross-env lookup (lines using server.webapp.fetch with friendlyId and jwt),
which masks a regression where auth could fail entirely; change the assertion to
require a 404 to assert env isolation (replace the
expect(res.status).not.toBe(200) / expect([401, 404]).toContain(res.status)
checks with an explicit expect(res.status).toBe(404)), or alternatively add a
control fetch using the same jwt against a known env-A resource to assert the
JWT is valid before asserting the cross-env lookup returns 404; update
assertions around server.webapp.fetch and res.status accordingly.

In `@internal-packages/rbac/src/fallback.ts`:
- Around line 34-107: authenticateBearer currently only checks public JWTs and
runtimeEnvironment.apiKey, so Personal Access Tokens (PATs) are never
recognized; add a PAT lookup branch after the runtimeEnvironment.findFirst call
fails: query the personalAccessToken (or equivalent) record using the rawToken,
validate it (not revoked/expired), load its associated
runtimeEnvironment/project/organization/orgMember as needed, set the returned
subject to type "personalAccessToken" with the token's
userId/organizationId/projectId, and return the appropriate environment
(toRbacEnvironment) and ability for PATs; ensure existing branches and error
returns remain unchanged and reuse symbols authenticateBearer,
runtimeEnvironment, toRbacEnvironment, and subject.type "personalAccessToken" so
callers get a valid PAT-authenticated response.

In `@internal-packages/rbac/src/index.ts`:
- Around line 80-93: The current catch treats all ERR_MODULE_NOT_FOUND the same;
update the ERR_MODULE_NOT_FOUND branch to inspect the error message
(err.message) for the actual module specifier that failed to load (the same
module specifier used in the dynamic import) and distinguish two cases: if the
missing specifier equals the plugin's import specifier then treat it as "no
plugin installed" and only log the lightweight fallback message when
process.env.RBAC_LOG_FALLBACK === "1"; otherwise treat it as a broken/transitive
dependency and log the full error loudly (use console.error with err) so it
surfaces in CI/production. Keep the existing variables isModuleNotFound and the
RBAC_LOG_FALLBACK check but add the err.message check to decide which message to
emit.

In `@packages/plugins/tsup.config.ts`:
- Line 3: The file currently uses a default export of defineConfig; replace this
with a named exported function per repo rules. Change the default export to a
function (e.g., export function tsupConfig() or export function
createTsupConfig()) that returns the defineConfig({...}) result and export it as
a named export so callers import the config by name rather than via a default
export; update any local references/imports to use the new named export (look
for defineConfig and the current default export usage).

---

Outside diff comments:
In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.team/route.tsx:
- Around line 362-380: The pending-invite action buttons (ResendButton and
RevokeButton) are still rendered even when the viewer only has read:members;
wrap or conditionally render those controls behind the canManageMembers flag so
only users with permission see them—e.g., guard the action container (the div
containing ResendButton and RevokeButton) or each button with {canManageMembers
&& ...} in the invites.map rendering to prevent dead UI and potential auth gaps.

---

Nitpick comments:
In `@apps/webapp/test/helpers/seedTestRun.ts`:
- Around line 6-10: Replace the exported interface SeededRun with an exported
type alias: define "export type SeededRun = { run: TaskRun; runFriendlyId:
string; batchFriendlyId?: string }" so the shape remains identical but uses a
type instead of an interface; update any imports/uses referencing SeededRun if
needed to ensure the symbol name remains the same.

In `@apps/webapp/test/helpers/seedTestSession.ts`:
- Around line 15-29: The SESSION_SECRET and cookie session shape are duplicated;
centralize them by extracting the shared config into a single exported constant
(e.g., TEST_SESSION_SECRET and TEST_SESSION_STORAGE_CONFIG or a single
TEST_SESSION object) and import it here instead of redefining SESSION_SECRET and
calling createCookieSessionStorage locally; update uses of SESSION_SECRET and
sessionStorage in this file to consume the shared export and remove the local
duplicate definitions (referencing the symbols SESSION_SECRET, sessionStorage,
and createCookieSessionStorage to locate the current code to replace).

In `@packages/plugins/src/rbac.ts`:
- Around line 81-88: Replace the exported interface declarations with exported
type aliases to follow the repo TypeScript convention; specifically change
RbacAbility (and the other exported interfaces in this file between lines
~104-216) from "export interface X { ... }" to "export type X = { ... }" while
preserving the exact members and names (e.g., can, canSuper on RbacAbility and
any RbacResource, RbacPolicy, etc.), keeping the public contract identical
except using type aliases instead of interfaces.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 222ec396-3f09-4be3-ae04-d454d2f49ded

📥 Commits

Reviewing files that changed from the base of the PR and between 30bd567 and f5dabbe.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (105)
  • .changeset/rbac-assignable-role-ids.md
  • .changeset/rbac-authenticate-authorize-arrays.md
  • .changeset/rbac-mutation-result-types.md
  • .changeset/rbac-plugin-array-resources.md
  • .changeset/rbac-system-role-ids-method.md
  • .changeset/rbac-system-roles.md
  • .github/workflows/e2e-webapp-auth-full.yml
  • .server-changes/rbac-apibuilder-migration.md
  • .server-changes/rbac-dashboard-builder.md
  • .server-changes/rbac-force-fallback.md
  • .server-changes/rbac-invite-role-picker.md
  • .server-changes/rbac-pat-role-selection.md
  • apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
  • apps/webapp/app/components/primitives/Select.tsx
  • apps/webapp/app/env.server.ts
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/models/organization.server.ts
  • apps/webapp/app/models/project.server.ts
  • apps/webapp/app/presenters/TeamPresenter.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/admin._index.tsx
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx
  • apps/webapp/app/routes/admin.back-office.tsx
  • apps/webapp/app/routes/admin.concurrency.tsx
  • apps/webapp/app/routes/admin.feature-flags.tsx
  • apps/webapp/app/routes/admin.llm-models.$modelId.tsx
  • apps/webapp/app/routes/admin.llm-models._index.tsx
  • apps/webapp/app/routes/admin.llm-models.missing.$model.tsx
  • apps/webapp/app/routes/admin.llm-models.missing._index.tsx
  • apps/webapp/app/routes/admin.llm-models.new.tsx
  • apps/webapp/app/routes/admin.notifications.tsx
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/routes/admin.tsx
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.query.schema.ts
  • apps/webapp/app/routes/api.v1.query.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.events.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.runs.ts
  • apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v3.batches.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/realtime.v1.batches.$batchId.ts
  • apps/webapp/app/routes/realtime.v1.runs.$runId.ts
  • apps/webapp/app/routes/realtime.v1.runs.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • apps/webapp/app/services/platform.v3.server.ts
  • apps/webapp/app/services/projectCreated.server.ts
  • apps/webapp/app/services/rbac.server.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
  • apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts
  • apps/webapp/app/services/routeBuilders/dashboardBuilder.ts
  • apps/webapp/app/utils/pathBuilder.ts
  • apps/webapp/package.json
  • apps/webapp/test/README.md
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/test/auth-dashboard.e2e.full.test.ts
  • apps/webapp/test/helpers/seedTestPAT.ts
  • apps/webapp/test/helpers/seedTestRun.ts
  • apps/webapp/test/helpers/seedTestSession.ts
  • apps/webapp/test/helpers/seedTestUserProject.ts
  • apps/webapp/test/helpers/seedTestWaitpoint.ts
  • apps/webapp/test/helpers/sharedTestServer.ts
  • apps/webapp/test/setup/global-e2e-full-setup.ts
  • apps/webapp/vitest.e2e.full.config.ts
  • internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql
  • internal-packages/database/prisma/schema.prisma
  • internal-packages/rbac/package.json
  • internal-packages/rbac/src/ability.test.ts
  • internal-packages/rbac/src/ability.ts
  • internal-packages/rbac/src/fallback.ts
  • internal-packages/rbac/src/index.ts
  • internal-packages/rbac/src/loader.test.ts
  • internal-packages/rbac/tsconfig.json
  • internal-packages/rbac/vitest.config.ts
  • internal-packages/testcontainers/src/utils.ts
  • internal-packages/testcontainers/src/webapp.ts
  • packages/plugins/package.json
  • packages/plugins/src/index.ts
  • packages/plugins/src/rbac.ts
  • packages/plugins/tsconfig.json
  • packages/plugins/tsup.config.ts

Comment thread .github/workflows/e2e-webapp-auth-full.yml
Comment thread apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
Comment thread apps/webapp/app/routes/account.tokens/route.tsx
Comment thread apps/webapp/app/routes/admin.llm-models.missing._index.tsx
Comment thread apps/webapp/app/services/projectCreated.server.ts
Comment thread apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
Comment thread internal-packages/rbac/src/fallback.ts
Comment thread internal-packages/rbac/src/index.ts
Comment thread packages/plugins/tsup.config.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal-packages/rbac/src/index.ts (1)

85-100: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

message.includes(moduleName) still can't tell “plugin missing” from “plugin broken”.

A transitive ERR_MODULE_NOT_FOUND usually includes the parent plugin path in the message, so this branch can still classify a broken installed plugin as “not installed” and quietly fall back.

Suggested fix
-      const isPluginItselfMissing =
-        isModuleNotFound && message.includes(moduleName);
+      const missingSpecifier =
+        message.match(/Cannot find (?:package|module) ['"]([^'"]+)['"]/)?.[1];
+      const isPluginItselfMissing =
+        isModuleNotFound && missingSpecifier === moduleName;
In Node.js ESM, when dynamic `import("@triggerdotdev/plugins/rbac")` fails because a transitive dependency is missing, can the thrown `ERR_MODULE_NOT_FOUND` / `MODULE_NOT_FOUND` message still include the parent plugin path, making `err.message.includes("@triggerdotdev/plugins/rbac")` true even though the missing specifier is different?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal-packages/rbac/src/index.ts` around lines 85 - 100, The current check
using message.includes(moduleName) can misclassify a plugin with a missing
transitive dependency as "not installed"; instead parse the missing-specifier
from the thrown error message and compare it exactly to moduleName. Update the
logic around isModuleNotFound / isPluginItselfMissing: when err.code is
"ERR_MODULE_NOT_FOUND" or "MODULE_NOT_FOUND", extract the missing specifier from
err.message with a regex (e.g. capture the quoted module path from messages like
"Cannot find module 'X' imported from Y" or similar) and set
isPluginItselfMissing only when that extracted specifier === moduleName (or
equals the package name form you expect); fall back to the existing
console.error branch when you cannot reliably extract or it doesn’t match.
Ensure you reference the same variables (moduleName, isModuleNotFound,
isPluginItselfMissing) and use the parsedSpecifier in the comparison.
🧹 Nitpick comments (1)
apps/webapp/test/auth-api.e2e.full.test.ts (1)

725-760: ⚡ Quick win

Add the mixed-task denial case for v2 as well.

This block only proves the happy path. It doesn’t lock in the actual security fix for api.v2.tasks.batch: batchTrigger:tasks:taskA must still be rejected for [taskA, taskB]. Since v1 and v2 duplicate the authorization closure in separate route files, v1’s negative test won’t catch a future drift in v2.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/test/auth-api.e2e.full.test.ts` around lines 725 - 760, Add a
negative test to the "Trigger task — batch v2 (api.v2.tasks.batch) sanity" suite
that mirrors the v1 mixed-task denial: use getTestServer(),
seedTestEnvironment(), and generateJWT() to create a token whose scopes only
allow batchTrigger:tasks:taskA, then POST to "/api/v2/tasks/batch" with items
containing both taskA and taskB and assert the response is rejected (expect 401
or 403). Place the test alongside the existing "JWT with write:tasks: auth
passes" case and ensure it checks for the denial status (not accepted) to lock
in v2's authorization behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/webapp/app/routes/account.tokens/route.tsx`:
- Around line 114-123: The defaultRoleId can point to an unassignable role;
change its calculation so it is clamped to the actual assignable roles shown in
the picker: when computing defaultRoleId, check if userRoleId exists in the
rendered roles list (the same source used to render the select) and use it only
if found, otherwise fall back to lowestAvailable (from rbac.systemRoles) or "";
update the assignment of defaultRoleId (currently using userRoleId ??
lowestAvailable) to perform this membership check against roles (and keep the
existing fallback behavior).

---

Duplicate comments:
In `@internal-packages/rbac/src/index.ts`:
- Around line 85-100: The current check using message.includes(moduleName) can
misclassify a plugin with a missing transitive dependency as "not installed";
instead parse the missing-specifier from the thrown error message and compare it
exactly to moduleName. Update the logic around isModuleNotFound /
isPluginItselfMissing: when err.code is "ERR_MODULE_NOT_FOUND" or
"MODULE_NOT_FOUND", extract the missing specifier from err.message with a regex
(e.g. capture the quoted module path from messages like "Cannot find module 'X'
imported from Y" or similar) and set isPluginItselfMissing only when that
extracted specifier === moduleName (or equals the package name form you expect);
fall back to the existing console.error branch when you cannot reliably extract
or it doesn’t match. Ensure you reference the same variables (moduleName,
isModuleNotFound, isPluginItselfMissing) and use the parsedSpecifier in the
comparison.

---

Nitpick comments:
In `@apps/webapp/test/auth-api.e2e.full.test.ts`:
- Around line 725-760: Add a negative test to the "Trigger task — batch v2
(api.v2.tasks.batch) sanity" suite that mirrors the v1 mixed-task denial: use
getTestServer(), seedTestEnvironment(), and generateJWT() to create a token
whose scopes only allow batchTrigger:tasks:taskA, then POST to
"/api/v2/tasks/batch" with items containing both taskA and taskB and assert the
response is rejected (expect 401 or 403). Place the test alongside the existing
"JWT with write:tasks: auth passes" case and ensure it checks for the denial
status (not accepted) to lock in v2's authorization behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cafa6923-4f13-4957-8535-c3b55198bc38

📥 Commits

Reviewing files that changed from the base of the PR and between f5dabbe and 398ca55.

📒 Files selected for processing (8)
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/app/services/projectCreated.server.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • internal-packages/rbac/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/webapp/app/services/projectCreated.server.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (15)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Use types over interfaces for TypeScript
Avoid using enums; prefer string unions or const objects instead

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
{packages/core,apps/webapp}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use zod for validation in packages/core and apps/webapp

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use function declarations instead of default exports

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

**/*.ts: When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
Do not use high-cardinality attributes in OTEL metrics such as UUIDs/IDs (envId, userId, runId, projectId, organizationId), unbounded integers (itemCount, batchSize, retryCount), timestamps (createdAt, startTime), or free-form strings (errorMessage, taskName, queueName)
When exporting OTEL metrics via OTLP to Prometheus, be aware that the exporter automatically adds unit suffixes to metric names (e.g., 'my_duration_ms' becomes 'my_duration_ms_milliseconds', 'my_counter' becomes 'my_counter_total'). Account for these transformations when writing Grafana dashboards or Prometheus queries

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
apps/webapp/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

apps/webapp/**/*.{ts,tsx}: Access environment variables through the env export of env.server.ts instead of directly accessing process.env
Use subpath exports from @trigger.dev/core package instead of importing from the root @trigger.dev/core path

Use named constants for sentinel/placeholder values (e.g. const UNSET_VALUE = '__unset__') instead of raw string literals scattered across comparisons

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
{apps,internal-packages}/**/*.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

Use pnpm run typecheck to verify changes in apps and internal packages (apps/*, internal-packages/*) instead of build, which proves almost nothing about correctness

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
{package.json,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (CLAUDE.md)

Pin Zod to version 3.25.76 exactly across the entire monorepo - never use a different version or version range

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
**/*.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js}: Import from @trigger.dev/core using subpaths only, never the root export
Always import tasks from @trigger.dev/sdk, never from @trigger.dev/sdk/v3 or deprecated client.defineJob
Add crumbs to code using // @Crumbs comments or `// `#region` `@crumbs blocks for debug tracing during development

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
**/*.{ts,tsx,js,jsx,json,md,css,scss}

📄 CodeRabbit inference engine (AGENTS.md)

Code formatting is enforced using Prettier. Run pnpm run format before committing

Files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use vitest for all tests in the Trigger.dev repository

Files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
apps/webapp/**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Do not import env.server.ts directly or indirectly into test files; instead pass environment-dependent values through options/parameters to make code testable

For testable code, never import env.server.ts in test files. Pass configuration as options instead (e.g., realtimeClient.server.ts takes config as constructor arg, realtimeClientGlobal.server.ts creates singleton with env config)

Files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.test.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.test.{ts,tsx,js}: Use vitest exclusively for testing and never mock anything - use testcontainers instead
Place test files next to source files using the pattern MyService.ts -> MyService.test.ts

**/*.test.{ts,tsx,js}: Use vitest for unit testing and run tests with pnpm run test
Test files should live beside the files under test with descriptive describe and it blocks
Tests should avoid mocks or stubs and use helpers from @internal/testcontainers when Redis or Postgres are needed

Files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use testcontainers with redisTest, postgresTest, or containerTest from @internal/testcontainers for testing with Redis/PostgreSQL dependencies

Files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
apps/webapp/**/*.{tsx,jsx}

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Only use useCallback/useMemo for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations

Files:

  • apps/webapp/app/routes/account.tokens/route.tsx
apps/webapp/**/*.server.ts

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

apps/webapp/**/*.server.ts: Never use request.signal for detecting client disconnects. Use getRequestAbortSignal() from app/services/httpAsyncStorage.server.ts instead, which is wired directly to Express res.on('close') and fires reliably
Access environment variables via env export from app/env.server.ts. Never use process.env directly
Always use findFirst instead of findUnique in Prisma queries. findUnique has an implicit DataLoader that batches concurrent calls and has active bugs even in Prisma 6.x (uppercase UUIDs returning null, composite key SQL correctness issues, 5-10x worse performance). findFirst is never batched and avoids this entire class of issues

Files:

  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
🧠 Learnings (46)
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `batch.triggerByTask()` to batch trigger multiple tasks by passing task instances

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `task.batchTrigger()` to trigger multiple runs of a task from inside another task

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `batch.triggerByTaskAndWait()` to batch trigger multiple tasks by instance and wait for results

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-04-16T14:19:16.330Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-04-16T14:19:16.330Z
Learning: Applies to apps/webapp/{app/v3/services/triggerTask.server.ts,app/v3/services/batchTriggerV3.server.ts} : In `triggerTask.server.ts` and `batchTriggerV3.server.ts`, do NOT add database queries. Task defaults (TTL, etc.) are resolved via `backgroundWorkerTask.findFirst()` in the queue concern (`queues.server.ts`). Piggyback on the existing query instead of adding new ones

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `batch.triggerAndWait()` to trigger multiple different tasks and wait for all results

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `task.batchTriggerAndWait()` to batch trigger a task and wait for all results from inside another task

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-31T21:37:31.732Z
Learnt from: isshaddad
Repo: triggerdotdev/trigger.dev PR: 3283
File: docs/migration-n8n.mdx:19-21
Timestamp: 2026-03-31T21:37:31.732Z
Learning: In the trigger.dev SDK (`packages/trigger-sdk/src/v3`), `tasks.triggerAndWait()` and `tasks.batchTriggerAndWait()` are real, valid exported APIs defined in `shared.ts` and re-exported via the `tasks` object in `tasks.ts`. They accept a task ID string as their first argument (not a task instance). These are distinct from the instance methods `yourTask.triggerAndWait()` and `yourTask.batchTriggerAndWait()`. Do not flag `tasks.triggerAndWait()` or `tasks.batchTriggerAndWait()` as non-existent APIs.

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-04-16T14:19:16.330Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-04-16T14:19:16.330Z
Learning: Applies to apps/webapp/app/v3/services/{cancelTaskRun,batchTriggerV3}.server.ts : When editing services that branch on `RunEngineVersion` to support both V1 and V2 (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`), only modify V2 code paths

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Use `tasks.batchTrigger()` to trigger multiple runs of a single task from backend code with different payloads

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `idempotencyKeys.create()` to create idempotency keys for task triggering to ensure idempotent operations

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
📚 Learning: 2026-04-13T21:44:00.032Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3368
File: apps/webapp/app/services/taskIdentifierRegistry.server.ts:24-67
Timestamp: 2026-04-13T21:44:00.032Z
Learning: In `apps/webapp/app/services/taskIdentifierRegistry.server.ts`, the sequential upsert/updateMany/findMany writes in `syncTaskIdentifiers` are intentionally NOT wrapped in a Prisma transaction. This function runs only during deployment-change events (low-concurrency path), and any partial `isInLatestDeployment` state is acceptable because it self-corrects on the next deployment. Do not flag this as a missing-transaction/atomicity issue in future reviews.

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-04-07T14:12:59.018Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3331
File: apps/webapp/app/runEngine/concerns/batchPayloads.server.ts:112-136
Timestamp: 2026-04-07T14:12:59.018Z
Learning: In `apps/webapp/app/runEngine/concerns/batchPayloads.server.ts`, the `pRetry` call wrapping `uploadPacketToObjectStore` intentionally retries **all** error types (no `shouldRetry` filter / `AbortError` guards). The maintainer explicitly prefers over-retrying to under-retrying because multiple heterogeneous object store backends are supported and it is impractical to enumerate all permanent error signatures. Do not flag this as an issue in future reviews.

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
📚 Learning: 2026-05-01T15:45:05.096Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3499
File: internal-packages/rbac/src/fallback.ts:34-107
Timestamp: 2026-05-01T15:45:05.096Z
Learning: In triggerdotdev/trigger.dev, `authenticateBearer` (in both the OSS RBAC fallback `internal-packages/rbac/src/fallback.ts` and the cloud RBAC plugin) is intentionally scoped to runtime environment API keys and Public JWTs only. Personal Access Token (PAT) authentication is handled by a separate route builder `createLoaderPATApiRoute` which calls `authenticateApiRequestWithPersonalAccessToken` directly. Do not flag the absence of PAT handling inside `authenticateBearer` as a bug — the two auth paths are architecturally distinct and this is consistent on both OSS and cloud sides.

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `task()` from `trigger.dev/sdk` for basic task definitions with `id` and `run` properties

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Use `batch.trigger()` to trigger multiple different tasks at once from backend code

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-22T13:26:12.060Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3244
File: apps/webapp/app/components/code/TextEditor.tsx:81-86
Timestamp: 2026-03-22T13:26:12.060Z
Learning: In the triggerdotdev/trigger.dev codebase, do not flag `navigator.clipboard.writeText(...)` calls for `missing-await`/`unhandled-promise` issues. These clipboard writes are intentionally invoked without `await` and without `catch` handlers across the project; keep that behavior consistent when reviewing TypeScript/TSX files (e.g., usages like in `apps/webapp/app/components/code/TextEditor.tsx`).

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
📚 Learning: 2026-03-22T19:24:14.403Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3187
File: apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts:200-204
Timestamp: 2026-03-22T19:24:14.403Z
Learning: In the triggerdotdev/trigger.dev codebase, webhook URLs are not expected to contain embedded credentials/secrets (e.g., fields like `ProjectAlertWebhookProperties` should only hold credential-free webhook endpoints). During code review, if you see logging or inclusion of raw webhook URLs in error messages, do not automatically treat it as a credential-leak/secrets-in-logs issue by default—first verify the URL does not contain embedded credentials (for example, no username/password in the URL, no obvious secret/token query params or fragments). If the URL is credential-free per this project’s conventions, allow the logging.

Applied to files:

  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • internal-packages/rbac/src/index.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
📚 Learning: 2026-04-20T15:06:19.815Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3417
File: apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts:37-51
Timestamp: 2026-04-20T15:06:19.815Z
Learning: In `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts` (and all session realtime read paths), `$replica` is intentionally used for the `resolveSessionByIdOrExternalId` call — including the `closedAt` guard in the PUT/initialize path. The project convention is to use `$replica` consistently across all session realtime routes. The race window (replica lag allowing a ghost-initialize after close) is accepted as not realistic in practice (clients follow the close API response; they do not race it). If replica lag ever causes issues, the mitigation is to revisit all realtime routes together, not to swap individual routes to `prisma`. Do not flag `$replica` usage in session realtime routes as a stale-read issue.

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2026-04-20T15:06:11.054Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3417
File: apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.append.ts:16-26
Timestamp: 2026-04-20T15:06:11.054Z
Learning: In `apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.append.ts` and `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts`, the `MAX_APPEND_BODY_BYTES` cap of 512 KiB (1024 * 512) is intentional even though `appendPart` wraps the body in JSON (which could expand quote-heavy payloads beyond S2's 1 MiB per-record limit). The maintainer considers worst-case quote-heavy payloads pathological and not realistic. If S2 rejections occur in practice, an encoded-size guard will be added inside `appendPart` rather than lowering the raw body cap on every caller. Do not flag this as an issue in future reviews.

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2026-04-16T13:24:09.546Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3399
File: apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts:26-42
Timestamp: 2026-04-16T13:24:09.546Z
Learning: In `apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts`, `RedisRealtimeStreams` is only ever instantiated once as a process-wide singleton via `singleton("realtimeStreams", initializeRedisRealtimeStreams)` in `apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts` (line 30). Therefore, the instance-level `_sharedRedis` field and `sharedRedis` getter are effectively process-scoped. Do not flag them as a per-request connection leak. The v2 streaming path uses a completely separate class (`S2RealtimeStreams`).

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2026-03-25T15:29:25.889Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2026-03-25T15:29:25.889Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `metadata.stream()` to stream data in realtime from inside tasks

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2025-07-12T18:06:04.133Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 2264
File: apps/webapp/app/services/runsRepository.server.ts:172-174
Timestamp: 2025-07-12T18:06:04.133Z
Learning: In apps/webapp/app/services/runsRepository.server.ts, the in-memory status filtering after fetching runs from Prisma is intentionally used as a workaround for ClickHouse data delays. This approach is acceptable because the result set is limited to a maximum of 100 runs due to pagination, making the performance impact negligible.

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2026-04-16T14:19:16.330Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-04-16T14:19:16.330Z
Learning: Applies to apps/webapp/app/v3/services/queues.server.ts : If adding a new task-level default, add it to the existing `select` clause in the `backgroundWorkerTask.findFirst()` query in `queues.server.ts` — do NOT add a second query. If the default doesn't need to be known at trigger time, resolve it at dequeue time instead

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2026-03-24T10:42:43.111Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3255
File: apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts:100-100
Timestamp: 2026-03-24T10:42:43.111Z
Learning: In `apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts` (and related span-handling code in trigger.dev), `span.entity` is a required (non-optional) field on the `SpanDetail` type and is always present. Do not flag `span.entity.type` as a potential null pointer / suggest optional chaining (`span.entity?.type`) in this context.

Applied to files:

  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
📚 Learning: 2026-04-27T16:39:43.098Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3453
File: internal-packages/run-engine/src/engine/systems/debounceSystem.ts:517-547
Timestamp: 2026-04-27T16:39:43.098Z
Learning: In `internal-packages/run-engine/src/engine/systems/debounceSystem.ts`, the `try/catch` around `runLock.lock(...)` in `handleExistingRun` routes errors matching `#isLockContentionError` (`LockAcquisitionTimeoutError`, `name === "ExecutionError"`, `name === "ResourceLockedError"`) to a fallback. This is intentionally NOT guarded by a `lockAcquired` flag because the only code executed inside the lock callback (`#handleExistingRunLocked`) calls Prisma and ioredis, neither of which emits errors with those names — those names are redlock-specific. There are no nested `runLock.lock` calls in this path so callback-thrown errors cannot be misclassified. A `lockAcquired` guard should be revisited only if a nested lock call is ever introduced inside `#handleExistingRunLocked`.

Applied to files:

  • internal-packages/rbac/src/index.ts
📚 Learning: 2026-05-01T15:45:05.096Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3499
File: internal-packages/rbac/src/fallback.ts:34-107
Timestamp: 2026-05-01T15:45:05.096Z
Learning: When reviewing triggerdotdev/trigger.dev RBAC auth code, do not treat missing Personal Access Token (PAT) handling inside `authenticateBearer` as a bug. `authenticateBearer` is intentionally scoped to runtime environment API keys and Public JWTs only; PAT auth is handled via the separate PAT route builder (e.g., `createLoaderPATApiRoute`) which calls `authenticateApiRequestWithPersonalAccessToken` directly. Ensure that reviewers compare auth behavior against these distinct architectural paths (OSS fallback and cloud plugin) before flagging an issue.

Applied to files:

  • internal-packages/rbac/src/index.ts
📚 Learning: 2026-05-01T15:45:05.518Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3499
File: apps/webapp/test/auth-cross-cutting.e2e.full.test.ts:206-213
Timestamp: 2026-05-01T15:45:05.518Z
Learning: In `apps/webapp/test/auth-cross-cutting.e2e.full.test.ts`, the cross-environment JWT isolation test intentionally asserts `expect([401, 404]).toContain(res.status)` rather than a strict `expect(res.status).toBe(404)`. The dual-status assertion is deliberate: both 401 (auth rejected) and 404 (resource not found in the resolved env) prove the negative — that the JWT cannot access a resource scoped to a different environment. The loose assertion is kept so a planned change to the auth response code (e.g. returning 404 instead of 401 for cross-env mismatch) does not immediately break this test. The control case that proves the JWT itself is valid is covered by other tests in the same describe block.

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-04-07T14:12:18.946Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3331
File: apps/webapp/test/engine/batchPayloads.test.ts:5-24
Timestamp: 2026-04-07T14:12:18.946Z
Learning: In `apps/webapp/test/engine/batchPayloads.test.ts`, using `vi.mock` for `~/v3/objectStore.server` (stubbing `hasObjectStoreClient` and `uploadPacketToObjectStore`), `~/env.server` (overriding offload thresholds), and `~/v3/tracer.server` (stubbing `startActiveSpan`) is intentional and acceptable. Simulating controlled transient upload failures (e.g., fail N times then succeed) to verify `p-retry` behavior cannot be reproduced with real services or testcontainers. This file is an explicit exception to the repo's general no-mocks policy.

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-04-16T13:45:22.317Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3368
File: apps/webapp/test/engine/taskIdentifierRegistry.test.ts:3-19
Timestamp: 2026-04-16T13:45:22.317Z
Learning: In `apps/webapp/test/engine/taskIdentifierRegistry.test.ts`, the `vi.mock` calls for `~/services/taskIdentifierCache.server` (stubbing `getTaskIdentifiersFromCache` and `populateTaskIdentifierCache`), `~/models/task.server` (stubbing `getAllTaskIdentifiers`), and `~/db.server` (stubbing `prisma` and `$replica`) are intentional. The suite uses real Postgres via testcontainers for all `TaskIdentifier` DB operations, but isolates the Redis cache layer and legacy query fallback as separate concerns not exercised in this test file. Do not flag these mocks as violations of the no-mocks policy in future reviews.

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-04-15T15:39:31.575Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/webapp.mdc:0-0
Timestamp: 2026-04-15T15:39:31.575Z
Learning: Applies to apps/webapp/**/*.test.{ts,tsx} : Do not import `env.server.ts` directly or indirectly into test files; instead pass environment-dependent values through options/parameters to make code testable

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2025-11-27T16:26:37.432Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-27T16:26:37.432Z
Learning: Applies to **/*.{test,spec}.{ts,tsx} : Use vitest for all tests in the Trigger.dev repository

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-03-02T12:43:25.254Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: internal-packages/run-engine/CLAUDE.md:0-0
Timestamp: 2026-03-02T12:43:25.254Z
Learning: Applies to internal-packages/run-engine/src/engine/tests/**/*.test.ts : Implement tests for RunEngine in `src/engine/tests/` using testcontainers for Redis and PostgreSQL containerization

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-05-01T15:24:56.404Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-05-01T15:24:56.404Z
Learning: Applies to **/*.test.{ts,tsx,js} : Use vitest exclusively for testing and never mock anything - use testcontainers instead

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-04-16T14:19:16.330Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-04-16T14:19:16.330Z
Learning: Applies to apps/webapp/**/*.test.{ts,tsx} : For testable code, never import `env.server.ts` in test files. Pass configuration as options instead (e.g., `realtimeClient.server.ts` takes config as constructor arg, `realtimeClientGlobal.server.ts` creates singleton with env config)

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2026-03-03T13:07:33.177Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3166
File: internal-packages/run-engine/src/batch-queue/tests/index.test.ts:711-713
Timestamp: 2026-03-03T13:07:33.177Z
Learning: In `internal-packages/run-engine/src/batch-queue/tests/index.test.ts`, test assertions for rate limiter stubs can use `toBeGreaterThanOrEqual` rather than exact equality (`toBe`) because the consumer loop may call the rate limiter during empty pops in addition to actual item processing, and this over-calling is acceptable in integration tests.

Applied to files:

  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-05-01T15:44:47.539Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3499
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx:170-173
Timestamp: 2026-05-01T15:44:47.539Z
Learning: In `apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx`, the `CreateRoleUpsell` component is intentionally only rendered for non-enterprise plans (`!isEnterprise`). Enterprise plans intentionally have no "Create role" CTA because the create-role flow is not yet built. A real Create button will be added in a separate ticket alongside the create-role action wiring. Do not flag the missing enterprise create-role entry point as a bug until that ticket lands.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-02-04T16:34:48.876Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/routes/vercel.connect.tsx:13-27
Timestamp: 2026-02-04T16:34:48.876Z
Learning: In apps/webapp/app/routes/vercel.connect.tsx, configurationId may be absent for "dashboard" flows but must be present for "marketplace" flows. Enforce this with a Zod superRefine and pass installationId to repository methods only when configurationId is defined (omit the field otherwise).

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-04-29T21:49:48.296Z
Learnt from: isshaddad
Repo: triggerdotdev/trigger.dev PR: 3475
File: apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx:61-70
Timestamp: 2026-04-29T21:49:48.296Z
Learning: In `apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx`, the local form state (`refillRate`, `intervalStr`, `maxTokens`) is intentionally seeded only once via `useState` initializers from `current` (the effective token-bucket config). This is safe in the Remix model because: (1) a successful save redirects, causing a remount with fresh loader data; (2) a failed 400 returns no redirect, so `current` stays the same and React preserves the user's typed input; (3) navigating to a different org causes remount and re-seeds state; (4) Cancel explicitly re-seeds via `cancelEdit()`. Do NOT add a `useEffect` that re-seeds from `current` on config changes — it would clobber mid-edit valid input during background revalidation, which is a regression.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2025-11-27T16:26:37.432Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-27T16:26:37.432Z
Learning: Applies to internal-packages/database/**/*.{ts,tsx} : Use Prisma for database interactions in internal-packages/database with PostgreSQL

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-03-13T13:42:25.092Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3213
File: apps/webapp/app/routes/admin.llm-models.new.tsx:65-91
Timestamp: 2026-03-13T13:42:25.092Z
Learning: In `apps/webapp/app/routes/admin.llm-models.new.tsx`, sequential Prisma writes for model/tier creation are intentionally not wrapped in a transaction. The form is admin-only with low concurrency risk, and the blast radius is considered minimal for admin tooling.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-02-03T18:27:40.429Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx:553-555
Timestamp: 2026-02-03T18:27:40.429Z
Learning: In apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx, the menu buttons (e.g., Edit with PencilSquareIcon) in the TableCellMenu are intentionally icon-only with no text labels as a compact UI pattern. This is a deliberate design choice for this route; preserve the icon-only behavior for consistency in this file.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-02-11T16:37:32.429Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3019
File: apps/webapp/app/components/primitives/charts/Card.tsx:26-30
Timestamp: 2026-02-11T16:37:32.429Z
Learning: In projects using react-grid-layout, avoid relying on drag-handle class to imply draggability. Ensure drag-handle elements only affect dragging when the parent grid item is configured draggable in the layout; conditionally apply cursor styles based on the draggable prop. This improves correctness and accessibility.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-04-02T19:18:26.255Z
Learnt from: samejr
Repo: triggerdotdev/trigger.dev PR: 3319
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx:179-189
Timestamp: 2026-04-02T19:18:26.255Z
Learning: In this repo’s route components that render the Inspector `ResizablePanelGroup` panels, it’s acceptable to pass `collapsed={!isShowingInspector}` together with a no-op `onCollapseChange={() => {}}` when panel visibility is intentionally controlled only by route parameters (e.g., `*Param` search/route params) rather than user drag/collapse interactions. Do not flag an empty/no-op `onCollapseChange` as “missing wiring” in these cases; only flag it when collapse state is expected to change based on user interaction.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
📚 Learning: 2026-04-16T14:19:16.330Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-04-16T14:19:16.330Z
Learning: Applies to apps/webapp/**/*.server.ts : Access environment variables via `env` export from `app/env.server.ts`. Never use `process.env` directly

Applied to files:

  • apps/webapp/app/routes/api.v1.tasks.batch.ts
📚 Learning: 2025-11-27T16:26:37.432Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-27T16:26:37.432Z
Learning: Applies to {packages/core,apps/webapp}/**/*.{ts,tsx} : Use zod for validation in packages/core and apps/webapp

Applied to files:

  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
📚 Learning: 2026-03-26T09:02:07.973Z
Learnt from: myftija
Repo: triggerdotdev/trigger.dev PR: 3274
File: apps/webapp/app/services/runsReplicationService.server.ts:922-924
Timestamp: 2026-03-26T09:02:07.973Z
Learning: When parsing Trigger.dev task run annotations in server-side services, keep `TaskRun.annotations` strictly conforming to the `RunAnnotations` schema from `trigger.dev/core/v3`. If the code already uses `RunAnnotations.safeParse` (e.g., in a `#parseAnnotations` helper), treat that as intentional/necessary for atomic, schema-accurate annotation handling. Do not recommend relaxing the annotation payload schema or using a permissive “passthrough” parse path, since the annotations are expected to be written atomically in one operation and should not contain partial/legacy payloads that would require a looser parser.

Applied to files:

  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Comment on lines +114 to +123
// Default the role picker to the user's own role in their primary
// org so a freshly-created PAT isn't more privileged than the
// person creating it. Falls back to the most-restrictive role
// available on the org's plan if they don't have one. When the
// user isn't a member of any org or no RBAC plugin is installed,
// the picker is hidden anyway, so defaultRoleId is just a
// placeholder.
const sys = orgId ? await rbac.systemRoles(orgId) : null;
const lowestAvailable = (sys ?? []).filter((r) => r.available).at(-1)?.id ?? "";
const defaultRoleId = userRoleId ?? lowestAvailable;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp defaultRoleId to the assignable system-role list.

defaultRoleId = userRoleId ?? lowestAvailable can point at a custom role or a plan-blocked system role, while the picker only renders roles. In that case the hidden roleId posts an invalid default and PAT creation bounces with the new 400 validation until the user manually changes the select.

Suggested fix
-    const sys = orgId ? await rbac.systemRoles(orgId) : null;
-    const lowestAvailable = (sys ?? []).filter((r) => r.available).at(-1)?.id ?? "";
-    const defaultRoleId = userRoleId ?? lowestAvailable;
+    const availableRoleIds = new Set(roles.map((role) => role.id));
+    const lowestAvailable = roles.at(-1)?.id ?? "";
+    const defaultRoleId =
+      userRoleId && availableRoleIds.has(userRoleId) ? userRoleId : lowestAvailable;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/account.tokens/route.tsx` around lines 114 - 123, The
defaultRoleId can point to an unassignable role; change its calculation so it is
clamped to the actual assignable roles shown in the picker: when computing
defaultRoleId, check if userRoleId exists in the rendered roles list (the same
source used to render the select) and use it only if found, otherwise fall back
to lowestAvailable (from rbac.systemRoles) or ""; update the assignment of
defaultRoleId (currently using userRoleId ?? lowestAvailable) to perform this
membership check against roles (and keep the existing fallback behavior).

matt-aitken added 26 commits May 3, 2026 10:04
…Client from public interface

- Replace buildBearerAbility/buildSessionAbility with authenticateBearer/authenticateSession
- Add RbacEnvironment, RbacUser, BearerAuthResult, SessionAuthResult types
- Remove PrismaClient from @trigger.dev/plugins interface (no Prisma crossing repo boundary)
- Remove @trigger.dev/database dependency and api-extractor from plugins package
- Switch plugins build to tsup --dts, delete api-extractor.json and tsconfig.dts.json
- OSS fallback imports PrismaClient from @trigger.dev/database directly
- OSS loader passes helpers-only to enterprise plugin, (prisma, helpers) to fallback
- Add rbac.server.ts singleton to webapp
- PoC: migrate admin.concurrency route to rbac.authenticateSession + canSuper()
Adds a `forceFallback` option to the RBAC loader so tests (and any other
consumer that sets RBAC_FORCE_FALLBACK=1) pin authentication to the OSS
fallback regardless of whether the enterprise plugin is installed.

- internal-packages/rbac: LazyController and RoleBaseAccess.create now accept
  RbacCreateOptions.forceFallback. When true, load() skips the dynamic import
  of @triggerdotdev/plugins/rbac and constructs RoleBaseAccessFallback
  directly.
- apps/webapp env: new RBAC_FORCE_FALLBACK BoolEnv, threaded into
  app/services/rbac.server.ts.
- testcontainers webapp: set RBAC_FORCE_FALLBACK=1 so e2e tests exercise the
  fallback deterministically.
- api-auth.e2e.test.ts: covers the fallback wiring end-to-end via the existing
  /admin/concurrency route, which already uses rbac.authenticateSession +
  ability.canSuper().
Close the coverage gap identified in the TRI-8716 audit before TRI-8719
swaps apiBuilder.server.ts to rbac.authenticateBearer. All new tests run
against the legacy authenticateApiRequestWithFailure /
authenticateApiRequestWithPersonalAccessToken path and must stay green
after the migration.

- Action requests (createActionApiRoute) via POST /api/v1/idempotencyKeys/:key/reset:
  * Valid private API key → passes auth (400 on zod body validation, not 401/403).
  * Missing Authorization → 401.
  * Invalid API key → 401.
- JWT on the same action route (allowJWT: true, superScopes write:runs, admin):
  * JWT with matching scope → passes auth.
  * JWT with read-only scope → 403.
- Personal access tokens (createLoaderPATApiRoute) via GET /api/v1/projects/:ref/runs:
  * Missing Authorization → 401.
  * API key (tr_dev_*) on PAT-only route → 401.
  * Wrong-prefix or malformed PAT → 401.
  * Well-formed but unknown PAT → 401.
  * Revoked PAT → 401.
  * Valid PAT on unknown project → 404 (auth passes).
- Edge case: valid API key whose project.deletedAt is set → 401.

Also fix the TRI-8715 redirect assertion: the webapp sends clients to
/login?redirectTo=... so compare by pathname rather than exact string.

New helper test/helpers/seedTestPAT.ts seeds a User + PersonalAccessToken
row using the same hashing/encryption scheme the webapp uses (shared
test ENCRYPTION_KEY), so the webapp subprocess can authenticate against
the seeded token.

otu and realtime.skipColumns propagation are deferred: they're only
observable via real trigger / realtime-stream flows, which need
workers/streams enabled and are out of scope for a coverage PR. The
migration preserves those fields via BearerAuthResult.jwt; dedicated
coverage can ride with TRI-8719 if needed.
Close the resource-scoped JWT coverage gap before TRI-8719 swaps
apiBuilder to rbac.authenticateBearer. Target:
POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT,
resource: { waitpoints: params.waitpointFriendlyId }, superScopes:
[write:waitpoints, admin].

New helper test/helpers/seedTestWaitpoint.ts seeds a Waitpoint in
COMPLETED status so the handler short-circuits once auth passes, keeping
the 200 assertion independent of run-engine workers.

7 new tests exercise the legacy checkAuthorization scope algebra that
the migration must preserve:

- scope matches exact resource id → 200
- scope targets a different id of the same type → 403
- type-level scope (no id) grants all resources of that type → 200
- read-only scope on a write route → 403
- scope targets a different resource type → 403
- admin super-scope → 200 (legacy super-scope listing)
- unrelated type scope with no super-scope match → 403

Without these, the only JWT coverage was coarse type-level allow/deny
against routes whose resource callbacks returned () => 1 or () => ({}),
leaving resource-id matching entirely untested end-to-end.
Lock in the legacy checkAuthorization behaviours that TRI-8719 must
preserve once it swaps in rbac.authenticateBearer + ability.can.

Three tests in a new describe block 'JWT bearer auth — behaviours to
preserve through TRI-8719':

- Custom action route: type-level write:tasks JWT on
  POST /api/v1/tasks/:taskId/trigger (action: trigger) → auth passes
  today via exact superScope match. Must keep passing after TRI-8719
  via the ACTION_ALIASES map (trigger ← write).
- Multi-key resource: read:tags:<tag> JWT on /api/v1/runs/:runId/trace
  where the seeded run has that tag → auth passes today because
  legacy checks each resource key. Must keep passing after TRI-8719
  via ability.can's array-resource form.
- Multi-key resource: read:batch:<friendlyId> JWT on
  /api/v1/runs/:runId/trace where the seeded run is in that batch →
  same rationale as the tags case.

Dropped the planned empty-resource test: researching it surfaced that
legacy checkAuthorization denies empty-resource requests BEFORE the
super-scope check runs, so api.v1.batches.ts and idempotencyKeys reset
currently reject all JWTs despite allowJWT: true. TRI-8719's plan
(adding explicit { type: 'runs' }) is an intentional improvement, not
a preservation — documented in the TRI-8719 description comment.

New helper test/helpers/seedTestRun.ts seeds a minimal TaskRun (and,
optionally, an associated BatchTaskRun) that ApiRetrieveRunPresenter's
findRun can resolve for multi-key resource tests. The tests only
assert 'auth passes' (!== 401, !== 403) — the handler's downstream
behaviour (which may fail in a worker-less test env) isn't relevant
to the auth-layer contract.
Foundational changes before swapping apiBuilder to rbac.authenticateBearer.
No behaviour change yet — apiBuilder is still on the legacy path.

Array resources:
- @trigger.dev/plugins RbacAbility.can now accepts RbacResource | RbacResource[].
  Array form means 'grant access if any element passes', preserving the
  legacy checkAuthorization multi-key semantic once TRI-8719 completes.
- internal-packages/rbac ability.ts: permissive/super/deny pass through
  unchanged; buildJwtAbility iterates the array and short-circuits on
  first match.

Action alias wrapper (internal-packages/rbac/src/index.ts):
- ACTION_ALIASES map + withActionAliases function. Wraps an underlying
  RbacAbility so that can(action, resource) retries with alias actions
  when the direct check fails. Currently: trigger, batchTrigger, update
  are all satisfied by a scope whose action is write — matching legacy
  superScope behaviour for route.action values that don't align with
  scope prefixes.
- LazyController wraps the ability it gets from authenticateBearer /
  authenticateSession. authenticateAuthorize* stop delegating to the
  underlying's own Authorize methods (that would bypass the wrapper)
  and instead do the inline ability.can check against the wrapped
  ability.

The enterprise plugin (TRI-8720) does not need to know about aliases —
the wrapper applies uniformly regardless of which ability came back.

Tests:
- ability.test.ts: +4 tests for array resource form (31 total in file).
- loader.test.ts: +11 tests for withActionAliases (direct match, alias
  retry for trigger/batchTrigger/update, id-scoped retry, admin passes,
  array form retry, canSuper delegation).
- Unit suite: 31 tests, all passing.
- Webapp typecheck: clean.
…I-8719 Phase B)

Swap all three apiBuilder call sites (loader, action, multi-method) from
authenticateApiRequestWithFailure + checkAuthorization to a single RBAC
plugin bridge. 30 route files migrated in lockstep — drop the
authorization.superScopes option, convert resource callbacks to return
RbacResource or RbacResource[] in the new shape.

Infrastructure:

- apiBuilder: new authenticateRequestForApiBuilder helper funnels all
  three builders through rbac.authenticateBearer and follow-up
  findEnvironmentById to rebuild the legacy ApiAuthenticationResultSuccess
  shape handlers still expect (no handler-facing changes).
- @internal/rbac: re-export RbacAbility, RbacResource from
  @trigger.dev/plugins so webapp only depends on @trigger.dev/rbac.

Route-file changes (Risk mitigations from the ticket):

- Custom actions (trigger, batchTrigger, update) unchanged at the route
  level — the ACTION_ALIASES wrapper from Phase A handles them
  transparently.
- Multi-key runs routes (api.v3.runs.$runId, realtime.v1.runs.$runId,
  realtime.v1.streams.$runId.$streamId, api.v1.runs.$runId.events /
  .spans.$spanId / .trace, realtime.v1.streams.$runId.input.$streamId
  second block, plus the batch-array routes) now return
  RbacResource[] — any resource match grants access. Undefined batch
  ids are filtered out to avoid accidentally matching a type-level
  read:batch scope with no id.
- Empty-resource routes (api.v1.batches, api.v1.idempotencyKeys.$key.reset)
  now return { type: 'runs' } — JWTs with read:runs / write:runs start
  working where they were previously denied by the legacy
  empty-resource short-circuit. Intentional improvement, strict
  superset of today's accept set.
- Search-params routes (realtime.v1.runs, api.v1.runs) return an array
  with a collection-level { type: 'runs' } plus any filtered tag/task
  entries so JWTs that work today continue to work.

Verification:

- pnpm run typecheck --filter webapp: clean.
- pnpm run test --filter @internal/rbac: 31 unit tests pass (wrapper +
  array-resource semantics).
- E2E suite (test/api-auth.e2e.test.ts): all 31 tests pass on the new
  code path — the three pre-migration 'behaviours to preserve' tests
  (type-level write:tasks triggers a task, read:tags:<tag> reaches a
  run with that tag, read:batch:<id> reaches a run in that batch) are
  still green post-swap.

Packaging:

- .changeset/rbac-plugin-array-resources.md: minor bump for
  @trigger.dev/plugins (array-resource overload on RbacAbility.can).
- .server-changes/rbac-apibuilder-migration.md: webapp-only note.
Add a session-auth route builder analogous to apiBuilder.server.ts that
routes dashboard auth through rbac.authenticateSession and runs the
ability check (canSuper or can) before the handler runs. Routes that
only need a logged-in user (no authorisation) keep using requireUser /
requireUserId — the builder is opt-in for routes with explicit auth.

Builder shape:

  dashboardLoader({ authorization: { requireSuper: true } }, async ({ user, ability }) => ...)
  dashboardLoader({ authorization: { action, resource } }, ...)
  dashboardAction(...)

Auth failure throws a redirect Response so the success-path return type
stays narrow (useTypedLoaderData<typeof loader>() picks up the handler's
TypedJsonResponse). Optional context callback feeds organizationId /
projectId to authenticateSession when needed (enterprise-only — fallback
ignores context today).

Migrated 14 platform admin routes from
`requireUser` + `if (!user.admin)` to dashboardLoader / dashboardAction
with requireSuper: true:

  admin.tsx
  admin._index.tsx
  admin.concurrency.tsx
  admin.feature-flags.tsx
  admin.notifications.tsx
  admin.orgs.tsx
  admin.data-stores.tsx
  admin.back-office.tsx
  admin.back-office._index.tsx
  admin.back-office.orgs.$orgId.tsx
  admin.llm-models._index.tsx
  admin.llm-models.$modelId.tsx
  admin.llm-models.new.tsx
  admin.llm-models.missing._index.tsx
  admin.llm-models.missing.$model.tsx

Routes that have admin-only sub-features (e.g. show-extra-fields-if-admin
on otherwise public routes) stay on requireUser. Migration of those is a
separate concern — they don't gate access on admin, they just branch
display.

Behavioural change: action handlers that previously threw
`new Response('Unauthorized', { status: 403 })` on non-admins now redirect
to / along with the loader. Uniform behaviour, but XHR fetchers that
expected a 403 status would now follow the redirect instead. The admin
pages migrated here don't appear to have XHR fetchers that depend on the
403, but worth flagging.

Verification:
- pnpm run typecheck --filter webapp: clean.
- pnpm run test --filter @internal/rbac: 31 unit tests pass.
- E2E suite: all 31 tests pass — including the
  /admin/concurrency redirect test (now exercising the new builder).
Widen check.resource on the convenience methods to RbacResource |
RbacResource[] so they match RbacAbility.can. Previously the interface
declared only RbacResource on these methods, which left an
inconsistency — anyone wanting to pass an array of resources had to
call authenticateBearer + ability.can manually instead of using the
convenience method.

Surfaced when reviewing the cloud enterprise controller (TRI-8720),
which had unilaterally widened its implementation to RbacResource[]
and would have failed type-check if any caller routed an array
through the typed interface.

Updated:

- packages/plugins/src/rbac.ts — RoleBaseAccessController interface.
- internal-packages/rbac/src/fallback.ts — RoleBaseAccessFallback
  matches.
- LazyController already uses Parameters<...> and tracks the
  interface, so it picks up the change automatically.

@trigger.dev/plugins gets a minor bump (changeset added).

Verification:

- pnpm run typecheck across @trigger.dev/plugins, @trigger.dev/rbac,
  webapp — clean.
- pnpm run test --filter @internal/rbac — 31 unit tests pass.
- e2e suite unaffected (no signature change at runtime — pure type
  widening).
…suite (TRI-8732)

Foundation for TRI-8731. The smoke api-auth.e2e.test.ts spins up its own
webapp + Postgres container per test file (~30s startup each). The
comprehensive matrix would have 12+ files, so per-file startup would
dominate runtime. Instead this harness boots one container for the whole
suite and rapid-fires tests across multiple files.

Layout:

- vitest.e2e.full.config.ts — globalSetup + pool: forks. Picks up
  test/**/*.e2e.full.test.ts.
- test/setup/global-e2e-full-setup.ts — calls startTestServer() once,
  provides baseUrl + databaseUrl to test workers via vitest's
  provide()/inject() API. Tears down on suite end.
- test/helpers/sharedTestServer.ts — getTestServer() pulls the provided
  values, constructs a per-worker PrismaClient, exposes
  { webapp, prisma } matching the existing TestServer shape.
- test/helpers/seedTestSession.ts — produces a Cookie header value
  compatible with the webapp's createCookieSessionStorage config so
  dashboard tests (TRI-8742) can authenticate as a seeded user.
- test/auth-api.e2e.full.test.ts, test/auth-dashboard.e2e.full.test.ts,
  test/auth-cross-cutting.e2e.full.test.ts — three file shells with
  top-level describe blocks. Family subtasks (TRI-8733+) add nested
  describes inside.
- .github/workflows/e2e-webapp-auth-full.yml — workflow_dispatch +
  nightly schedule + pull_request paths-filtered (only triggers on PRs
  touching auth-relevant files).
- test/README.md — documents the unit / smoke-e2e / full-e2e split.

Touching @internal/testcontainers:

- TestServer interface gains databaseUrl so per-worker PrismaClient
  reconstruction has the connection string without going through the
  serialised prisma instance (which can't cross worker boundaries).
- utils.ts — assertNonNullable's vitest import was previously eager at
  module load. globalSetup runs outside any vitest worker, so that
  eager init crashed (createExpect needs worker state). Switched to a
  lazy require('vitest') inside the function body. The function still
  runs in test workers where worker state exists.
- logs.ts — TaskContext changed to type-only import for the same
  module-load-time concern (transitively imported by webapp.ts).

Verification:

- pnpm run typecheck across @internal/testcontainers + webapp — clean.
- pnpm exec vitest run --config vitest.e2e.full.config.ts —
  3/3 tests pass in 19.37s with one observed container startup.
  Subsequent family subtasks add describes with no per-file container
  cost.

The placeholder it() in each file (just hits /healthcheck or counts
users) gets removed by the family subtasks as they add real coverage.
Mutation methods on RoleBaseAccessController now return discriminated
Result unions instead of throwing on expected error paths:

- RoleMutationResult — { ok: true; role: Role } | { ok: false; error }
  for createRole, updateRole.
- RoleAssignmentResult — { ok: true } | { ok: false; error: string }
  for deleteRole, setUserRole, removeUserRole, setTokenRole,
  removeTokenRole.

The cloud webapp surfaces the 'error' strings directly to users
(system role edits, plan-tier gating, validation conflicts), so a
thrown exception now signals only an unexpected failure (DB outage,
bug). Read methods (getUserRole, getTokenRole, allRoles,
allPermissions) are unchanged.

OSS fallback returns { ok: false, error: 'RBAC plugin not installed' }
for every mutation — matches the prior behaviour (createRole/updateRole
already threw with this message; the others were silent no-ops, which
made misuse hard to detect). The LazyController in @internal/rbac
forwards the new return types verbatim. Changeset: patch.

Customer-facing surface: only public type widening of mutation method
return types — no runtime behaviour change for OSS callers (they get
a Result error instead of a thrown error or silent no-op; both indicate
'do not call these without the enterprise plugin').
The dev build was crashing with 'dashboardLoader is not a function'
on first navigation to any /admin route, then the browser would
hard-reload back to the previous page. Symptom: clicking 'Admin
dashboard' (or anywhere /@ → /admin chain) flashed admin then bounced
back, with no obvious cause server-side (every loader returned 200).

Root cause: routes export their loader at module top-level via the
wrapper:
    export const loader = dashboardLoader(...);
The factory call evaluates at module load. dashboardBuilder lived in
a .server.ts file, which Remix strips from the client bundle. In the
prod build the loader export + its RHS are both tree-shaken, so the
import is unreferenced and removed — fine. In the dev build the call
is preserved (HMR/source-map friendliness) and resolves
dashboardLoader to undefined on the client, throwing on module load.
Remix's recovery is to reload the page, which lands on the previous
URL because that's the last known-good navigation entry.

Fix: split the wrapper so the import target exists on both server
and client.
- dashboardBuilder.ts (no .server) — exports types + dashboardLoader /
  dashboardAction wrappers. Wrappers return closures whose bodies
  dynamic-import the server impl. The closure body never runs on the
  client, so the dynamic import only resolves at server runtime.
  Client just sees a function that returns another function — the
  top-level call now works there.
- dashboardBuilder.server.ts — slimmed down to authenticateAndAuthorize
  + the redirect/authorization helpers. Imported via dynamic import
  from the wrapper. Stays out of the client bundle.

Routes drop the .server suffix on the import path. No change to the
route's loader/action surface. Verified end-to-end via Chrome
DevTools: /@ → /admin chain renders the admin dashboard cleanly,
no console errors, no extra document fetch back to the org URL.
…ting (TRI-8748)

Wire RBAC into the existing org Teams page (settings/team).

OSS plugin
- Adds RoleBaseAccessController.getAssignableRoleIds(orgId) — the
  subset of allRoles(orgId) that can be assigned right now. Returns
  [] in the OSS fallback (consistent with allRoles also returning []
  there). Pure UI affordance: server-side enforcement remains
  setUserRole's lookupAssignableRole. Public package change with
  patch-level changeset.

Enterprise plugin
- Implements getAssignableRoleIds against PlansClient: system roles
  pass through isSystemRoleAssignable (Owner/Admin always; Member /
  Viewer require Pro+); custom roles require canCreateCustomRoles
  (Enterprise tier). Mirrors the gates in setUserRole so UI and
  server agree.

Webapp
- TeamPresenter now also returns rbac.allRoles(orgId),
  getAssignableRoleIds(orgId), and per-member role assignments.
  Per-member is N+1 today (low-traffic settings page); a batched
  lookup is filed as a future optimisation.
- Route migrated from requireUserId to dashboardLoader / dashboardAction
  via the split builder (commit a2cdbfb). Loader gates on
  read:members; action stays permissive at the wrapper level so the
  existing remove/leave + purchase-seats flows keep working with
  their per-intent checks. New set-role intent gates on
  manage:members and calls rbac.setUserRole — surfaces the Result
  error inline next to the dropdown when the server rejects (system
  role rename, plan-gated assignment, foreign-org role).
- UI: native select next to each member, defaults to that member's
  current role. Options not in assignableRoleIds render disabled
  with an (upgrade) suffix. Auto-submits on change via fetcher.
  Invite + Remove buttons hide/disable when canManageMembers is
  false (server-side ability check pre-computed in the loader).
  Self-leave is always allowed regardless of manage:members.

Verification
- Typecheck clean across @internal/rbac, webapp, enterprise/plugins,
  enterprise/db, packages/plans.
- Browser smoke test deferred until webapp dev server is running.
Pairs with the enterprise/db backfill migration (cloud side) so every
new (user, org) pair gets a UserRole row from day one without anyone
falling through to PERMISSIVE_ABILITY on the Teams page.

Mapping mirrors the backfill (legacy ADMIN had full access; the new
Admin role excludes billing + member management, so legacy ADMIN
belongs in the new Owner slot, not the new Admin slot):

  legacy ADMIN  -> Owner  (sys_role_owner)
  legacy MEMBER -> Member (sys_role_member)

Changes:

- services/rbac.server.ts: export SYSTEM_ROLE_IDS constant. The IDs
  are seeded by the enterprise/db migration and never change; both
  org creation and invite acceptance import from here so the role
  reference is in one place.
- models/organization.server.ts: createOrganization calls
  rbac.setUserRole({ roleId: owner }) after the org row is created.
  Outside any transaction (rbac uses a separate Drizzle/postgres-js
  connection). On OSS the fallback returns ok=false; we log + continue
  since the legacy OrgMember.role write is the source of truth there.
- models/member.server.ts: acceptInvite assigns Owner if the invite
  was ADMIN (defensive — the current UI only invites with MEMBER) or
  Member otherwise. setUserRole runs after the prisma transaction
  commits for the same reason as above. Returns the same shape as
  before so callers don't change.

Verification: typecheck clean. Migration step (TRI-8854 part 1) is on
the cloud side; together they ensure both existing and new (user, org)
pairs land on a sensible RBAC role.
Lets users pick a system role at PAT-create time and persists it via
enterprise.TokenRole so PAT-authenticated requests will run with that
role's permissions once the auth-side wiring lands.

V1 scope decisions (worth flagging for review):

1. System roles only. PATs are user-scoped (not org-scoped) and
   custom roles are per-org — the role-to-org mapping for a multi-org
   user's PAT is a non-trivial design question that doesn't need to
   be answered for v1. Show the four seeded system roles
   (Owner/Admin/Member/Viewer); a follow-up can add custom roles
   once we've decided what "this PAT uses an org X custom role"
   means semantically.

2. Default to caller's own role. Loader queries rbac.getUserRole
   against the user's first org membership (createdAt ASC) and uses
   that as the dropdown default — a PAT can't be more privileged
   than the person creating it without an explicit upgrade. Falls
   back to Member for users with no role assignment yet (OSS or new
   user pre-backfill).

3. No plan gating. Plan tiers are per-org; PAT roles are global.
   Plan gating only made sense in the org-scoped Teams page UI
   (TRI-8748).

4. No privilege-escalation check. Today's PATs run through the
   legacy auth path with full superScopes — even a "Owner" PAT here
   is strictly less permissive than the status quo. Locking down
   "the PAT can't exceed the creator's role" is a hardening for a
   later ticket once the read-side actually keys off TokenRole.

Changes:

- services/personalAccessToken.server.ts: createPersonalAccessToken
  takes an optional roleId. When provided, calls rbac.setTokenRole
  after the Prisma PAT row is created. On a real failure the PAT is
  compensating-deleted (the two writes live on different ORMs sharing
  one connection — co-transactions are awkward, compensating delete
  is simpler). The OSS fallback's "RBAC plugin not installed" return
  is treated as success-with-no-role: the PAT row stays, just no
  TokenRole gets written, matching pre-RBAC behaviour.
- routes/account.tokens/route.tsx: loader fetches system roles +
  caller's current role; create form shows a role <Select> with the
  caller's role as default; OSS path (allRoles returns []) hides the
  dropdown entirely. Action passes roleId through to the service.

Out of scope here (covered elsewhere):

- The PAT auth-side path that will JOIN TokenRole and build an ability
  from the role's permissions. Lives in the enterprise plugin's
  authenticatePat path; tracked under the TRI-8741 test surface and
  the broader auth-consolidation work in TRI-8744.
- CLI auth-code PAT (createPersonalAccessTokenFromAuthorizationCode)
  unchanged. CLI PATs continue to be created without an explicit
  role — they go through the legacy permissive path and existing
  user expectations of "trigger dev just works" are preserved.

Verification: typecheck clean on webapp. Browser smoke test deferred
to your local run.
matt-aitken added 28 commits May 3, 2026 10:14
Every read-side $runId route computes its authorization resource
from the loaded TaskRun:

  [
    { type: "runs", id: run.friendlyId },
    { type: "tasks", id: run.taskIdentifier },
    ...run.runTags.map(tag => ({ type: "tags", id: tag })),
    run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId },
  ]

A JWT scope matching ANY array element grants access. Tests target
GET /api/v3/runs/:runId as the canonical route with the full matrix
(13 cases), plus a sanity check on /api/v1/runs/:runId/events to
confirm the wiring isn't route-local.

api.v3.runs.$runId — 13 cases:
  - missing auth → 401
  - invalid API key → 401
  - private API key → auth passes
  - JWT read:runs (type-level) → passes
  - JWT read:runs:<exact friendlyId> → passes
  - JWT read:runs:<other> → 403
  - JWT read:tags:<tag in run.runTags> → passes (array element match)
  - JWT read:tags:<tag NOT in runTags> → 403
  - JWT read:batch:<run.batch.friendlyId> → passes
  - JWT read:batch:<other> → 403
  - JWT read:tasks:<run.taskIdentifier> → passes
  - JWT read:all → passes
  - JWT admin → passes
  - JWT write:runs:<friendlyId> → 403 (action mismatch — read route)
  - cross-env: env A's JWT cannot read env B's run → not 200

api.v1.runs.$runId.events — 2-case sanity (missing auth, read:runs).
If a route in this family ever diverges from the canonical pattern,
add a dedicated describe.

Reuses seedTestRun({ withBatch, runTags }) — already in the helper
shipped with TRI-8716. No new fixtures.

Verification: typecheck clean. Test execution still blocked by the
e2e.full webapp-boot issue noted on TRI-8731.
Per-batch endpoints share a single-id resource config:
  resource: { type: "batch", id: batch.friendlyId }

Notable: the resource type is "batch", NOT "runs". The legacy
literal-match escape that let read:runs JWTs hit batch endpoints
no longer applies post-TRI-8719. Tests pin this down.

The list endpoint (GET /api/v1/batches) was deleted on the s3-
switchover branch — list-section coverage is N/A on this branch.
If/when the list endpoint returns, add a list-side describe.

api.v1.batches.$batchId — 10 cases:
  - missing auth → 401
  - invalid API key → 401
  - private API key on real batch → auth passes
  - JWT read:batch:<friendlyId> matching → passes
  - JWT read:batch:<other> → 403
  - JWT read:batch (type-level) → passes
  - JWT read:runs → 403 (resource type is "batch", not "runs"
    — pre-TRI-8719 this passed via legacy literal-match escape;
    locking in the post-migration strict behaviour)
  - JWT read:all → passes
  - JWT admin → passes
  - cross-env: env A's JWT cannot read env B's batch → not 200

api.v2.batches.$batchId — 2-case sanity (config identical to v1).
realtime.v1.batches.$batchId — 2-case sanity.

If a route in this family ever diverges from the canonical pattern,
add a dedicated describe.

Reuses seedTestRun({ withBatch: true }) — helper already creates
the BatchTaskRun + linked TaskRun for us.

Verification: typecheck clean. Test execution still blocked by the
e2e.full webapp-boot issue noted on TRI-8731.
Prompts route family — read + update actions, both single-id
({type:"prompts", id: params.slug}) and collection-level
({type:"prompts", id: "all"}) resource shapes.

Auth resolves before any DB lookup, so tests use non-existent
slugs throughout; handler 404s but auth-passed assertion
("not 401, not 403") is what the matrix verifies.

Coverage:

Prompts list — GET /api/v1/prompts (5 cases):
  - missing auth → 401
  - private API key → auth passes
  - JWT read:prompts → passes
  - JWT read:runs → 403 (type mismatch)
  - JWT admin → passes

Prompts retrieve — GET /api/v1/prompts/:slug (7 cases, full matrix):
  - missing auth → 401
  - private API key → passes
  - JWT read:prompts → passes
  - JWT read:prompts:<exact slug> → passes
  - JWT read:prompts:<other> → 403
  - JWT read:runs → 403 (type mismatch)
  - JWT admin → passes

Prompts override — POST /api/v1/prompts/:slug/override (6 cases):
  Tests the ACTION_ALIASES write→update behaviour:
  - missing auth → 401
  - JWT write:prompts:<slug> matching → passes
  - JWT write:prompts (type-level) → passes
  - JWT read:prompts → 403 (action mismatch — read NOT aliased)
  - JWT write:prompts:<other> → 403
  - JWT admin → passes

Promote/reactivate sanity (2 cases):
  - promote: JWT write:prompts → passes
  - reactivate: JWT read:prompts → 403

Multi-method override (POST/PUT/PATCH/DELETE) is not exhaustively
tested per-method — they share the same authorization config so
covering POST suffices. If a method ever overrides authorization,
add a targeted test.

Verification: typecheck clean. Test execution still blocked by the
e2e.full webapp-boot issue noted on TRI-8731.
Read-only family with distinct resource types per route:
  - GET /api/v1/deployments       { type: "deployments", id: "list" }
  - GET /api/v1/query/schema      { type: "query", id: "schema" }
  - GET /api/v1/query/dashboards  { type: "query", id: "dashboards" }
  - POST /api/v1/query            body-derived via detectTables(query)
                                  → tables.length > 0
                                    ? tables.map(id => ({type:"query", id}))
                                    : { type: "query", id: "all" }

Coverage:

Deployments list (7 cases):
  - missing auth → 401
  - private API key → passes
  - JWT read:deployments → passes
  - JWT read:all → passes
  - JWT admin → passes
  - JWT read:runs → 403 (type mismatch)
  - JWT write:deployments → 403 (action mismatch)

Query schema sanity (3 cases):
  - missing auth → 401
  - JWT read:query → passes
  - JWT read:deployments → 403 (type mismatch)

Query dashboards sanity (2 cases):
  - missing auth → 401
  - JWT read:query → passes

Query ad-hoc body-derived (6 cases):
  - missing auth → 401
  - body "SELECT * FROM runs" + JWT read:query:runs → passes
    (any-match against the body-derived array)
  - body "SELECT 1" (no detectable tables) + JWT read:query → passes
    (defaults to id="all"; type-level scope matches)
  - body with 'runs' + JWT read:query:other_table → 403
  - JWT admin → passes regardless of body
  - JWT write:query → 403 (action mismatch)

Verification: typecheck clean. Test execution still blocked by the
e2e.full webapp-boot issue noted on TRI-8731.

This closes the TRI-8731 test family (8733, 8734, 8735, 8736, 8737,
8738, 8739, 8740, 8741, 8742, 8743 — all done across today's
commits).
The e2e.full harness was failing to boot the webapp testcontainer
with `TypeError: Cannot convert undefined or null to object at
allMachines (build/index.js:71583)`. Root cause: the testcontainer
was setting NODE_ENV=test, which surfaces a circular-init order
regression in the production bundle that NODE_ENV=production
dodges (modules init in a different order under prod-mode and the
relevant singleton resolves before the cycle re-enters). Production
builds work fine — the harness just needs to match prod-mode boot.

Single one-line change in testcontainers: NODE_ENV is now
"production" instead of "test". Tests don't depend on test-mode
semantics — they just need an isolated webapp + DB.

After unblocking, fixed 11 tests whose strict 2xx assertions were
correct against a request-time-resolved handler but wrong against
the test container (where ClickHouse and external services are
dummy URLs):

- 8 tests on api.v1.runs and api.v1.projects/<ref>/runs (PAT route):
  the run-list presenter hits ClickHouse which 500s in tests. Auth
  passes; assertion changed from strict 200 to "not 401/403".
- 2 tests on api.v1.prompts/:slug (retrieve): the apiBuilder runs
  findResource BEFORE authorization. With no Prompt fixture seeded
  the route 404s before the auth check, so a non-matching scope
  appears as 404 rather than 403. Both states mean "user can't see"
  — assertion changed to "not 200" with a comment explaining the
  ordering.
- 1 test on prompts /override/reactivate: route's BodySchema requires
  `{ version: positive int }`. My empty body 400'd at validation
  before auth. Sending `{ version: 1 }` lets validation pass and
  the auth check fires; gets the expected 403.

Plus 1 fixture fix on auth-cross-cutting.e2e.full.test.ts: the
TaskRun.create call needed `queue: "task/test-task"` (matches the
seedTestRun helper).

Verification:
  pnpm exec vitest run --config vitest.e2e.full.config.ts
  → 3 files, 162 tests, all pass. ~14 seconds.
…859)

Replace the hardcoded `RBAC_FORCE_FALLBACK: "1"` env var with an
optional `forceRbacFallback` parameter on `startWebapp` and
`startTestServer`. Default `true` preserves OSS suite behaviour
(every existing call site keeps fallback-pinned semantics).

Cloud's enterprise variant of the e2e suite passes `false` so the
spawned webapp loads the real `@triggerdotdev/plugins/rbac` instead
of the OSS fallback. Same harness, different RBAC implementation
under test.

Verification: OSS e2e.full suite still 162/162 passes.
…(TRI-8731)

After rebasing rbac-packages on origin/main, the e2e.full harness
regressed with the same `TypeError: Cannot convert undefined or null
to object at allMachines (build/index.js:71862)` that TRI-8731
worked around by switching the testcontainer to NODE_ENV=production.
Recent main commits changed the bundled init order enough that the
production-mode dodge no longer applies — the cycle now triggers in
both NODE_ENV=test and NODE_ENV=production.

Root cause is structural:

  app/services/platform.v3.server  →  imports createEnvironment
                                      from app/models/organization.server
  app/models/organization.server   →  imports getDefaultEnvironmentConcurrencyLimit
                                      from app/services/platform.v3.server

Inside an esbuild __esm bundle, this manifests as:

  init_platform_v3_server() runs init_organization_server() in the
  middle of its body. organization.server's body re-enters
  init_platform_v3_server(), which short-circuits because the outer
  call already cleared its `fn` — so `({ defaultMachine, machines } =
  singleton("machinePresets", ...))` never completes its destructure
  and both vars stay undefined. Object.entries(undefined) crashes
  when `allMachines()` runs inside `createRunEngine()`.

Fix: move the only function in platform.v3.server.ts that imports
from organization.server (`projectCreated`, the sole caller of
`createEnvironment`) into its own file. platform.v3.server.ts no
longer imports from organization.server, so the cycle is gone. Two
trivial supporting changes:

  - export `isCloud` from platform.v3.server (projectCreated needs it)
  - drop the now-unused `Organization` and `Project` type imports

No dynamic imports, no application-code workarounds — just a
structural file split.

Verified:
  - 162/162 e2e.full pass (auth-api, auth-cross-cutting, auth-dashboard)
  - 31/31 api-auth.e2e pass
  - 31/31 @trigger.dev/rbac unit tests pass
  - 7/7 cloud enterprise e2e.full pass against the same webapp build
Adds a Settings → Roles page that lists every role visible to the org,
with each role's permissions rendered in a Table grouped by category
(Runs, Tasks, Waitpoints, Realtime, Deployments, Prompts, Query,
Tokens, Organisation, Wildcards). Each permission shows its name and
description.

Page surfaces:
  - Role name + description + System/Custom badge
  - "Not on this plan" badge for roles outside the current plan tier
    (system roles gated by PlansClient.isSystemRoleAssignable)
  - "Create role" button:
      - Free / Hobby / Pro: opens an "Upgrade to Enterprise" dialog
        with a Contact us CTA (deep-links to trigger.dev/contact)
      - Enterprise: hidden — the create-role UI is a follow-up after
        TRI-8747's controller-level CRUD already in place

Plumbing:
  - apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles
  - organizationRolesPath helper in pathBuilder
  - Roles SideMenuItem next to Team in OrganizationSettingsSideMenu
  - "View all role permissions →" link on the Teams page next to the
    Active team members section so an Owner about to assign a role can
    audit the choice
The enterprise plugin's getUserRole now derives a user's role from the
legacy public.OrgMember.role column whenever no explicit UserRole row
exists (separate cloud commit). That makes the upfront UserRole writes
in createOrganization and acceptInvite redundant — the role display
and ability checks both work from day one based on OrgMember alone.

Removed:
  - The rbac.setUserRole call + SYSTEM_ROLE_IDS import from
    apps/webapp/app/models/organization.server.ts (createOrganization)
  - The rbac.setUserRole call + SYSTEM_ROLE_IDS import from
    apps/webapp/app/models/member.server.ts (acceptInvite)

A UserRole row is now only ever inserted when an Owner explicitly
changes someone's role on the Teams page. Everyone else's role is
derived live from OrgMember.role.
Code comments throughout the OSS-facing RBAC surface mentioned the
enterprise plugin, CASL, the cloud webapp, the cloud-side test suite,
and specific cloud file paths. Two reasons not to keep that:

  - Reputation: comments framing the OSS code as "the OSS path" vs
    "the enterprise path" pollute the public repo with implementation
    framing that shouldn't be there.
  - Implementation leakage: enterprise/cloud comments give away
    structural details about the closed-source plugin (where its data
    lives, what library it uses, which Linear tickets track it).

Rewrites use neutral language — "the loaded RBAC plugin (if any)",
"the default fallback", "an installed plugin" — and drop references
to specific cloud-side files / TRI-IDs / CASL.

Plan-tier names ("Enterprise" as a public product tier in the Roles
page upsell, `planCode === "enterprise"` checks, `<TierEnterprise />`
in pre-existing files) are intentionally left as-is — they're the
public marketing name for a paid tier, not implementation detail.

Removed `.server-changes/rbac-userrole-default-assignment.md` —
documented a feature that was reverted in d2bf617 (upfront UserRole
inserts on create-org / acceptInvite).

Verified: 162/162 OSS e2e.full pass, 31/31 OSS rbac unit pass.
Pairs with the cloud-side change that removes `admin`, `read:all`,
and `write:all` from PERMISSION_CATALOGUE. With no catalogue entry
sitting in the Wildcards group, the corresponding entries in the
client-side PERMISSION_GROUP_BY_NAME map are dead and the group is
removed from GROUP_ORDER.
…I-8893)

Pairs with the cloud-side CASL refactor that switches role storage to
packed CASL rules + introduces conditional rules (e.g. Member's prod
env-var restrictions). Two interface changes here:

  - Permission gains optional `inverted` and `conditions` fields. The
    Roles page renders `inverted: true` rules as ✗ and `conditions`
    (e.g. `{ envType: "PRODUCTION" }`) as a tier badge.

  - RbacResource gains an open-ended `[key: string]: unknown` index so
    routes can pass condition-relevant fields alongside `type` / `id`
    (e.g. `{ type: "envvars", envType: env.type }`). The plugin's
    CASL-backed matcher reads these off the resource object.

Roles page UI: TableHeader gains an "Allowed" column rendering ✓/✗
per rule, and conditional rules show a `(production only)` /
`(non-production only)` Badge next to the permission name. Group order
gains a leading "All" for Owner/Admin's wildcard rules and an
"Environment" group for the new envvars/apiKeys catalogue pairs.
…8904)

Replaces the per-role tables with a single comparison grid: rows are
catalogue permissions grouped by category (Runs, Tasks, Environment,
…), columns are Owner, Admin, Developer, Member, then any
custom roles, then Description. Each cell shows whether that role
grants the permission.

Cell rendering driven by `effectivePermissions(role.rules)` (TRI-8893):

  - No matching rules → ✗ in muted colour
  - Allow rule(s), no inverted → ✓ in success green
  - Allow rule(s) plus a conditional `cannot` → ✓ green + a tier badge
    rendered beneath ("non-prod only" for envType=PRODUCTION etc.)
  - Only inverted unconditional rule → ✗ in error colour

Plan tier hint in column headers — Developer / Member columns get a
small "Pro" Badge on Free/Hobby; custom roles get "Enterprise". Cells
still render the comparison data so users see what they'd unlock.

Loader extended to call `rbac.allPermissions(orgId)` so the catalogue
drives the row enumeration. Owner column ends up with ✓ on every row
(one rendered Permission per catalogue entry, expanded from the
`manage:all` packed rule via CASL's rulesFor walk).

Also: `SYSTEM_ROLE_IDS` updated from `{owner, admin, member, viewer}`
to `{owner, admin, developer, member}` — Viewer was dropped in TRI-8893
when the role ladder finalised; this catches up the OSS-side helper.
account.tokens uses `SYSTEM_ROLE_IDS.member` as the PAT default; the
new (more restricted) Member is the right default for that flow.
Adds a Role `<Select>` to the invite form. Dropdown options are
filtered by:

  1. The inviter's own role — strictly below their level (Owner can
     pick any of the 4; Admin can pick Developer or Member; Developer/
     Member don't see the picker because they can't invite anyway).
  2. The org's plan tier — `rbac.getAssignableRoleIds(orgId)` already
     reflects this (Free/Hobby = Owner+Admin only, Pro+ unlocks
     Developer+Member).

The picker is hidden entirely when `rbac.allRoles(orgId)` returns []
(OSS deployments with no plugin installed) — legacy invite path is
unchanged for self-hosters.

Schema: nullable `OrgMemberInvite.rbacRoleId text` column. On accept,
if it's set, `acceptInvite` calls the plugin's `setUserRole` after
the OrgMember insert (outside the Prisma transaction since the plugin
uses a separate Drizzle / postgres-js connection — same compensating
pattern as PAT-role assignment). If it's null, the runtime fallback
derives a role from the legacy `OrgMember.role` write at first
auth — no behaviour change.

Server-side validation in the action layer rejects:

  - rbacRoleId not in `getAssignableRoleIds(orgId)` (plan-tier check).
  - rbacRoleId at or above the inviter's own level (the
    canInviteAtRole ladder).

Legacy `OrgMemberInvite.role` enum (ADMIN/MEMBER) is still written
based on the chosen RBAC role — Owner/Admin → "ADMIN", Developer/
Member → "MEMBER" — so OSS auth keeps working.

Verified:
  - typecheck clean
  - 162/162 OSS e2e.full
  - 7/7 cloud enterprise e2e.full
Adds a new `systemRoleIds(): Promise<SystemRoleIds | null>` method on
the `RoleBaseAccessController` interface. Returns
`{ owner, admin, developer, member }` from any installed plugin and
`null` from the default fallback (matches the `allRoles → []`
semantics — there are no seeded roles to refer to in OSS).

Drops the `SYSTEM_ROLE_IDS` constant from `~/services/rbac.server` so
consumers can't reach for hardcoded role-id strings. Updates the four
sites that used it:

  - `models/member.server.ts` (invite flow's legacy-role mapping)
  - `routes/account.tokens` (PAT default)
  - `routes/_app.orgs.$organizationSlug.settings.roles` (Roles page
    comparison grid column ordering + plan-tier badges)
  - `routes/_app.orgs.$organizationSlug.invite` (role picker)

The Roles page and invite route both pass the IDs through their
loaders rather than referencing them at module top level — which was
the root cause of the "Invite a team member button hard-refreshes the
dashboard" bug: importing a `.server.ts` symbol from client-rendered
code left a dangling client-bundle reference.

Verified: typecheck clean, 162/162 OSS e2e.full, 7/7 cloud
enterprise e2e.full.
SelectLinkItem passes render={<Link>} so the row navigates on click. In
non-Combobox Selects (most use cases — the role picker on the Teams
page, etc.) SelectItem was overriding render to undefined, silently
dropping the Link wrapper. Pass props.render through verbatim when
there's no Combobox; wrap in ComboboxItem only when one's present.
The OSS no longer needs to know individual role names. systemRoles(orgId)
returns a plugin-owned, ordered SystemRole[] (id, name, description,
available) — the cloud plugin owns the canonical order, the descriptions,
and the per-org plan-tier 'available' flag. Hidden roles (Member in v1)
are filtered out entirely.

OSS callers iterate the array and use array index for the level ladder;
no role-name strings except for the legacy OrgMember.role enum mapping
shim, which is now isolated to one filter in member.server.ts.
…ker tightening

#1 Batch trigger AND semantics (security): `api.v[12].tasks.batch` now uses
`everyResource(...)` so a JWT scoped to taskA can no longer submit a batch
that also includes taskB / taskC. Added an `everyResource` helper to
`apiBuilder` (Symbol-marked wrapper that flips `ability.can` to `every`).
Multi-key OR semantics still apply for single-resource arrays (a run carries
multiple identifiers). Updated the e2e test to assert AND behaviour.

#3 Realtime stream resource (correctness): `findResource` for
`realtime.v1.streams.$runId.$streamId` now selects `taskIdentifier`,
`runTags`, and `realtimeStreamsVersion` — fields the auth resource
builder + handler read but findResource was returning undefined for.

#4 projectCreated optional chaining (crash bug): added the missing
`?.` between v3Subscription and plan so a missing subscription no longer
throws and aborts project creation.

#5 RBAC plugin loader logging: distinguish "plugin itself missing" from
"plugin found but a transitive dep failed to resolve" by inspecting the
ERR_MODULE_NOT_FOUND error message for the plugin's own module specifier.
The transitive-dep case now logs at error level (matches the comment's
stated behaviour). Removed the orphan log line that contradicted it.

#6 account.tokens picker source mismatch: the picker now sources roles
from the same plan-tier-filtered list (`systemRoles().filter(available)`)
as the default-role calculation. Added server-side roleId revalidation
in the create action so a hand-crafted POST can't bind a PAT to an
unavailable role.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
apps/webapp/app/routes/account.tokens/route.tsx (1)

114-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp defaultRoleId to the same assignable role list you render.

userRoleId ?? lowestAvailable can still resolve to a custom or plan-blocked role that is absent from roles. In that case the hidden roleId submits an invalid default and the new server-side revalidation rejects PAT creation until the user changes the select. Compute the default from roles itself, and only reuse userRoleId when it exists in that list.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/account.tokens/route.tsx` around lines 114 - 123, The
defaultRoleId computation can pick a role not present in the rendered roles
list, causing an invalid hidden value; change defaultRoleId so it is derived
from the same assignable list used for the select (the roles variable) by: check
if userRoleId is included in roles' ids and use it only if present, otherwise
pick the last available role id from roles (or empty string); update any use of
lowestAvailable/sys to instead compute the fallback from roles so the submitted
hidden roleId is always valid for the rendered options.
🧹 Nitpick comments (2)
internal-packages/testcontainers/src/utils.ts (1)

193-199: Simplify assertNonNullable by removing the runtime require("vitest") pattern

The function works fine in the current CommonJS setup and is actively used across multiple test files. However, the lazy-loaded require("vitest") is unnecessarily complex. Since this function is only called in test contexts where vitest is already loaded, you can simplify it with a direct nullish check instead:

Proposed fix
 export function assertNonNullable<T>(value: T): asserts value is NonNullable<T> {
-  // Loaded lazily so importers of this module don't pay the vitest top-level
-  // init cost outside a test worker. See the import note at the top.
-  // eslint-disable-next-line `@typescript-eslint/no-require-imports`
-  const { expect } = require("vitest") as typeof import("vitest");
-  expect(value).toBeDefined();
-  expect(value).not.toBeNull();
+  if (value === null || value === undefined) {
+    throw new Error("Expected value to be non-nullable");
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal-packages/testcontainers/src/utils.ts` around lines 193 - 199, The
assertNonNullable function currently uses a lazy runtime require("vitest") and
expect assertions; replace that with a direct nullish check inside
assertNonNullable (check value === null || value === undefined) and throw a
clear Error (or TypeError) if the value is nullish so tests fail
deterministically; update the implementation of assertNonNullable to return the
narrowed type on success and remove the require("vitest") and expect(...) calls.
apps/webapp/app/services/personalAccessToken.server.ts (1)

20-25: ⚡ Quick win

Share the fallback sentinel from RBAC instead of matching a local copy.

PAT creation now treats one exact setTokenRole() error string as control flow. Since this contract also uses human-readable error text, a wording tweak in the fallback/controller will silently turn OSS PAT creation from “skip role assignment” into a hard failure. Please export a stable constant/code from the RBAC package and compare against that here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/services/personalAccessToken.server.ts` around lines 20 - 25,
Replace the local string sentinel FALLBACK_NOT_INSTALLED_ERROR with the exported
sentinel from the RBAC package: import the stable constant (e.g. the exported
name around setTokenRole in the rbac module) and compare against that value
where PAT creation handles setTokenRole errors so the compensating delete path
still triggers only for the canonical sentinel; update references to
FALLBACK_NOT_INSTALLED_ERROR to use the imported symbol and remove the
hard-coded string.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/webapp/app/models/member.server.ts`:
- Around line 248-263: In acceptInvite, don’t treat rbac.setUserRole failures as
non-fatal: when rbac.setUserRole returns !ok (the block checking
result.rbacRoleId currently only logs via logger.error), either perform
compensating cleanup (remove the just-created org membership / undo invite
consumption using the membership or invite service) or surface the failure to
the caller by throwing an error so the invite is not consumed; reference the
existing acceptInvite flow and the rbac.setUserRole call and ensure the invite
consumption/membership creation is rolled back or the error is propagated
(alternatively implement a retry/outbox for setUserRole) instead of silently
continuing.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.roles/route.tsx:
- Around line 50-63: The loader currently returns an empty context when
resolveOrgIdFromSlug(params.organizationSlug) is falsy, allowing the
authorization check to run without org context; instead, in the dashboardLoader
call change the context function to throw a 404 (e.g. throw new Response("Not
Found", { status: 404 })) when resolveOrgIdFromSlug returns falsy so the
not-found check runs before authenticate/authorization; update the context
implementation used in route loader (the context callback inside dashboardLoader
that calls resolveOrgIdFromSlug) to throw the Response rather than returning {}
so authorization sees only valid orgs.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.team/route.tsx:
- Around line 189-197: The "purchase-seats" branch currently returns json({ ok:
false, error: "Organization not found" }) with a 200; instead make the
missing-organization case return a 404. In the formType === "purchase-seats"
block (where you call $replica.organization.findFirst with organizationSlug),
change the early return to either throw new Response(JSON.stringify({ ok: false,
error: "Organization not found" }), { status: 404 }) or return json({ ok: false,
error: "Organization not found" }, { status: 404 }) so the HTTP status matches
the error condition.

In `@apps/webapp/test/auth-api.e2e.full.test.ts`:
- Around line 204-226: The tests use loose negative assertions like
expect(res.status).not.toBe(200) which also pass on 500s; update each affected
test (e.g., the "cross-env: env A's JWT cannot complete env B's waitpoint: not
200" case and the other occurrences noted) to assert the explicit allowed
failure statuses instead of "not 200". Replace expect(res.status).not.toBe(200)
with a precise check such as expect([401, 403, 404]).toContain(res.status) (or
the exact set your app may legitimately return for auth/lookup failures) and do
the same in other tests that currently use the negative assertion; reference the
helper functions generateJWT, seedTestEnvironment, seedEnvAndWaitpoint,
completeRequest, and pathFor to locate the tests to change.

---

Duplicate comments:
In `@apps/webapp/app/routes/account.tokens/route.tsx`:
- Around line 114-123: The defaultRoleId computation can pick a role not present
in the rendered roles list, causing an invalid hidden value; change
defaultRoleId so it is derived from the same assignable list used for the select
(the roles variable) by: check if userRoleId is included in roles' ids and use
it only if present, otherwise pick the last available role id from roles (or
empty string); update any use of lowestAvailable/sys to instead compute the
fallback from roles so the submitted hidden roleId is always valid for the
rendered options.

---

Nitpick comments:
In `@apps/webapp/app/services/personalAccessToken.server.ts`:
- Around line 20-25: Replace the local string sentinel
FALLBACK_NOT_INSTALLED_ERROR with the exported sentinel from the RBAC package:
import the stable constant (e.g. the exported name around setTokenRole in the
rbac module) and compare against that value where PAT creation handles
setTokenRole errors so the compensating delete path still triggers only for the
canonical sentinel; update references to FALLBACK_NOT_INSTALLED_ERROR to use the
imported symbol and remove the hard-coded string.

In `@internal-packages/testcontainers/src/utils.ts`:
- Around line 193-199: The assertNonNullable function currently uses a lazy
runtime require("vitest") and expect assertions; replace that with a direct
nullish check inside assertNonNullable (check value === null || value ===
undefined) and throw a clear Error (or TypeError) if the value is nullish so
tests fail deterministically; update the implementation of assertNonNullable to
return the narrowed type on success and remove the require("vitest") and
expect(...) calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 52ff2a4b-21d8-4821-b8e2-d8b711d13bcb

📥 Commits

Reviewing files that changed from the base of the PR and between 398ca55 and 977089c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (105)
  • .changeset/rbac-assignable-role-ids.md
  • .changeset/rbac-authenticate-authorize-arrays.md
  • .changeset/rbac-mutation-result-types.md
  • .changeset/rbac-plugin-array-resources.md
  • .changeset/rbac-system-role-ids-method.md
  • .changeset/rbac-system-roles.md
  • .github/workflows/e2e-webapp-auth-full.yml
  • .server-changes/rbac-apibuilder-migration.md
  • .server-changes/rbac-dashboard-builder.md
  • .server-changes/rbac-force-fallback.md
  • .server-changes/rbac-invite-role-picker.md
  • .server-changes/rbac-pat-role-selection.md
  • apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
  • apps/webapp/app/components/primitives/Select.tsx
  • apps/webapp/app/env.server.ts
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/models/organization.server.ts
  • apps/webapp/app/models/project.server.ts
  • apps/webapp/app/presenters/TeamPresenter.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/admin._index.tsx
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx
  • apps/webapp/app/routes/admin.back-office.tsx
  • apps/webapp/app/routes/admin.concurrency.tsx
  • apps/webapp/app/routes/admin.feature-flags.tsx
  • apps/webapp/app/routes/admin.llm-models.$modelId.tsx
  • apps/webapp/app/routes/admin.llm-models._index.tsx
  • apps/webapp/app/routes/admin.llm-models.missing.$model.tsx
  • apps/webapp/app/routes/admin.llm-models.missing._index.tsx
  • apps/webapp/app/routes/admin.llm-models.new.tsx
  • apps/webapp/app/routes/admin.notifications.tsx
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/routes/admin.tsx
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.query.schema.ts
  • apps/webapp/app/routes/api.v1.query.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.events.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.runs.ts
  • apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v3.batches.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/realtime.v1.batches.$batchId.ts
  • apps/webapp/app/routes/realtime.v1.runs.$runId.ts
  • apps/webapp/app/routes/realtime.v1.runs.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • apps/webapp/app/services/platform.v3.server.ts
  • apps/webapp/app/services/projectCreated.server.ts
  • apps/webapp/app/services/rbac.server.ts
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
  • apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts
  • apps/webapp/app/services/routeBuilders/dashboardBuilder.ts
  • apps/webapp/app/utils/pathBuilder.ts
  • apps/webapp/package.json
  • apps/webapp/test/README.md
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/test/auth-dashboard.e2e.full.test.ts
  • apps/webapp/test/helpers/seedTestPAT.ts
  • apps/webapp/test/helpers/seedTestRun.ts
  • apps/webapp/test/helpers/seedTestSession.ts
  • apps/webapp/test/helpers/seedTestUserProject.ts
  • apps/webapp/test/helpers/seedTestWaitpoint.ts
  • apps/webapp/test/helpers/sharedTestServer.ts
  • apps/webapp/test/setup/global-e2e-full-setup.ts
  • apps/webapp/vitest.e2e.full.config.ts
  • internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql
  • internal-packages/database/prisma/schema.prisma
  • internal-packages/rbac/package.json
  • internal-packages/rbac/src/ability.test.ts
  • internal-packages/rbac/src/ability.ts
  • internal-packages/rbac/src/fallback.ts
  • internal-packages/rbac/src/index.ts
  • internal-packages/rbac/src/loader.test.ts
  • internal-packages/rbac/tsconfig.json
  • internal-packages/rbac/vitest.config.ts
  • internal-packages/testcontainers/src/utils.ts
  • internal-packages/testcontainers/src/webapp.ts
  • packages/plugins/package.json
  • packages/plugins/src/index.ts
  • packages/plugins/src/rbac.ts
  • packages/plugins/tsconfig.json
  • packages/plugins/tsup.config.ts
✅ Files skipped from review due to trivial changes (43)
  • .server-changes/rbac-force-fallback.md
  • apps/webapp/app/models/project.server.ts
  • apps/webapp/app/components/primitives/Select.tsx
  • packages/plugins/tsconfig.json
  • apps/webapp/vitest.e2e.full.config.ts
  • internal-packages/rbac/tsconfig.json
  • apps/webapp/test/helpers/seedTestWaitpoint.ts
  • apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
  • .server-changes/rbac-pat-role-selection.md
  • internal-packages/rbac/package.json
  • packages/plugins/package.json
  • apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
  • apps/webapp/app/services/rbac.server.ts
  • .server-changes/rbac-invite-role-picker.md
  • packages/plugins/src/index.ts
  • apps/webapp/app/routes/api.v1.query.schema.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
  • internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql
  • apps/webapp/app/routes/admin.back-office.tsx
  • .server-changes/rbac-apibuilder-migration.md
  • internal-packages/rbac/src/loader.test.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/models/organization.server.ts
  • apps/webapp/app/routes/api.v1.runs.ts
  • packages/plugins/tsup.config.ts
  • apps/webapp/test/helpers/seedTestRun.ts
  • apps/webapp/app/routes/realtime.v1.runs.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
  • apps/webapp/app/services/projectCreated.server.ts
  • apps/webapp/test/helpers/seedTestSession.ts
  • apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
  • apps/webapp/app/presenters/TeamPresenter.server.ts
  • internal-packages/database/prisma/schema.prisma
  • apps/webapp/package.json
  • .server-changes/rbac-dashboard-builder.md
  • .changeset/rbac-system-roles.md
  • apps/webapp/app/routes/realtime.v1.runs.$runId.ts
  • internal-packages/rbac/src/ability.test.ts
  • apps/webapp/app/routes/admin.llm-models.$modelId.tsx
  • packages/plugins/src/rbac.ts
  • internal-packages/testcontainers/src/webapp.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.ts
  • internal-packages/rbac/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (35)
  • apps/webapp/test/setup/global-e2e-full-setup.ts
  • .changeset/rbac-authenticate-authorize-arrays.md
  • apps/webapp/app/routes/admin.concurrency.tsx
  • .changeset/rbac-system-role-ids-method.md
  • .changeset/rbac-plugin-array-resources.md
  • apps/webapp/test/helpers/seedTestUserProject.ts
  • apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.events.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts
  • apps/webapp/app/routes/api.v3.batches.ts
  • apps/webapp/test/helpers/seedTestPAT.ts
  • apps/webapp/app/routes/admin.llm-models.missing.$model.tsx
  • apps/webapp/app/routes/admin.tsx
  • internal-packages/rbac/src/ability.ts
  • apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts
  • apps/webapp/app/routes/api.v1.tasks.batch.ts
  • apps/webapp/app/services/routeBuilders/dashboardBuilder.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts
  • apps/webapp/app/routes/admin._index.tsx
  • apps/webapp/app/routes/admin.llm-models.new.tsx
  • apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
  • .github/workflows/e2e-webapp-auth-full.yml
  • apps/webapp/app/routes/admin.feature-flags.tsx
  • apps/webapp/app/services/platform.v3.server.ts
  • apps/webapp/app/utils/pathBuilder.ts
  • apps/webapp/app/routes/realtime.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.notifications.tsx
  • apps/webapp/test/auth-dashboard.e2e.full.test.ts
  • apps/webapp/app/routes/admin.llm-models.missing._index.tsx
  • apps/webapp/app/routes/api.v1.query.ts
  • apps/webapp/test/helpers/sharedTestServer.ts
  • apps/webapp/app/routes/admin.llm-models._index.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
  • apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx
  • apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (29)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / e2e-webapp / 🧪 E2E Tests: Webapp
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: sdk-compat / Node.js 20.20 (ubuntu-latest)
  • GitHub Check: sdk-compat / Node.js 22.12 (ubuntu-latest)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (15)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Use types over interfaces for TypeScript
Avoid using enums; prefer string unions or const objects instead

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use function declarations instead of default exports

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

**/*.ts: When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
Do not use high-cardinality attributes in OTEL metrics such as UUIDs/IDs (envId, userId, runId, projectId, organizationId), unbounded integers (itemCount, batchSize, retryCount), timestamps (createdAt, startTime), or free-form strings (errorMessage, taskName, queueName)
When exporting OTEL metrics via OTLP to Prometheus, be aware that the exporter automatically adds unit suffixes to metric names (e.g., 'my_duration_ms' becomes 'my_duration_ms_milliseconds', 'my_counter' becomes 'my_counter_total'). Account for these transformations when writing Grafana dashboards or Prometheus queries

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
{apps,internal-packages}/**/*.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

Use pnpm run typecheck to verify changes in apps and internal packages (apps/*, internal-packages/*) instead of build, which proves almost nothing about correctness

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
{package.json,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (CLAUDE.md)

Pin Zod to version 3.25.76 exactly across the entire monorepo - never use a different version or version range

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js}: Import from @trigger.dev/core using subpaths only, never the root export
Always import tasks from @trigger.dev/sdk, never from @trigger.dev/sdk/v3 or deprecated client.defineJob
Add crumbs to code using // @Crumbs comments or `// `#region` `@crumbs blocks for debug tracing during development

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.{ts,tsx,js,jsx,json,md,css,scss}

📄 CodeRabbit inference engine (AGENTS.md)

Code formatting is enforced using Prettier. Run pnpm run format before committing

Files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
  • apps/webapp/test/README.md
{packages/core,apps/webapp}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use zod for validation in packages/core and apps/webapp

Files:

  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
apps/webapp/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

apps/webapp/**/*.{ts,tsx}: Access environment variables through the env export of env.server.ts instead of directly accessing process.env
Use subpath exports from @trigger.dev/core package instead of importing from the root @trigger.dev/core path

Use named constants for sentinel/placeholder values (e.g. const UNSET_VALUE = '__unset__') instead of raw string literals scattered across comparisons

Files:

  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
apps/webapp/**/*.{tsx,jsx}

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Only use useCallback/useMemo for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations

Files:

  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use vitest for all tests in the Trigger.dev repository

Files:

  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
apps/webapp/**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Do not import env.server.ts directly or indirectly into test files; instead pass environment-dependent values through options/parameters to make code testable

For testable code, never import env.server.ts in test files. Pass configuration as options instead (e.g., realtimeClient.server.ts takes config as constructor arg, realtimeClientGlobal.server.ts creates singleton with env config)

Files:

  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.test.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.test.{ts,tsx,js}: Use vitest exclusively for testing and never mock anything - use testcontainers instead
Place test files next to source files using the pattern MyService.ts -> MyService.test.ts

**/*.test.{ts,tsx,js}: Use vitest for unit testing and run tests with pnpm run test
Test files should live beside the files under test with descriptive describe and it blocks
Tests should avoid mocks or stubs and use helpers from @internal/testcontainers when Redis or Postgres are needed

Files:

  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use testcontainers with redisTest, postgresTest, or containerTest from @internal/testcontainers for testing with Redis/PostgreSQL dependencies

Files:

  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
apps/webapp/**/*.server.ts

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

apps/webapp/**/*.server.ts: Never use request.signal for detecting client disconnects. Use getRequestAbortSignal() from app/services/httpAsyncStorage.server.ts instead, which is wired directly to Express res.on('close') and fires reliably
Access environment variables via env export from app/env.server.ts. Never use process.env directly
Always use findFirst instead of findUnique in Prisma queries. findUnique has an implicit DataLoader that batches concurrent calls and has active bugs even in Prisma 6.x (uppercase UUIDs returning null, composite key SQL correctness issues, 5-10x worse performance). findFirst is never batched and avoids this entire class of issues

Files:

  • apps/webapp/app/env.server.ts
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
🧠 Learnings (8)
📚 Learning: 2026-03-22T13:26:12.060Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3244
File: apps/webapp/app/components/code/TextEditor.tsx:81-86
Timestamp: 2026-03-22T13:26:12.060Z
Learning: In the triggerdotdev/trigger.dev codebase, do not flag `navigator.clipboard.writeText(...)` calls for `missing-await`/`unhandled-promise` issues. These clipboard writes are intentionally invoked without `await` and without `catch` handlers across the project; keep that behavior consistent when reviewing TypeScript/TSX files (e.g., usages like in `apps/webapp/app/components/code/TextEditor.tsx`).

Applied to files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-03-22T19:24:14.403Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3187
File: apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts:200-204
Timestamp: 2026-03-22T19:24:14.403Z
Learning: In the triggerdotdev/trigger.dev codebase, webhook URLs are not expected to contain embedded credentials/secrets (e.g., fields like `ProjectAlertWebhookProperties` should only hold credential-free webhook endpoints). During code review, if you see logging or inclusion of raw webhook URLs in error messages, do not automatically treat it as a credential-leak/secrets-in-logs issue by default—first verify the URL does not contain embedded credentials (for example, no username/password in the URL, no obvious secret/token query params or fragments). If the URL is credential-free per this project’s conventions, allow the logging.

Applied to files:

  • internal-packages/rbac/vitest.config.ts
  • apps/webapp/app/routes/api.v2.batches.$batchId.ts
  • apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
  • apps/webapp/app/routes/api.v1.query.dashboards._index.ts
  • apps/webapp/app/routes/api.v1.batches.$batchId.ts
  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/env.server.ts
  • internal-packages/testcontainers/src/utils.ts
  • apps/webapp/app/routes/api.v2.tasks.batch.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
  • apps/webapp/app/routes/api.v1.deployments.ts
  • apps/webapp/app/routes/api.v3.runs.$runId.ts
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/services/personalAccessToken.server.ts
  • internal-packages/rbac/src/fallback.ts
  • apps/webapp/test/api-auth.e2e.test.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
  • apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
  • apps/webapp/test/auth-api.e2e.full.test.ts
📚 Learning: 2026-05-01T15:45:08.099Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3499
File: packages/plugins/tsup.config.ts:3-3
Timestamp: 2026-05-01T15:45:08.099Z
Learning: In build/tool configuration files (e.g., tsup.config.ts, vite.config.ts, vitest.config.ts), follow the tool’s documented export pattern and use `export default defineConfig(...)` (or the equivalent documented default export). The repo-wide guideline “use named exports instead of default exports” should apply only to application code (*.{ts,tsx,js,jsx}), not to these build/tool config files—so do not flag `export default defineConfig(...)` in these config files as a violation.

Applied to files:

  • internal-packages/rbac/vitest.config.ts
📚 Learning: 2026-02-03T18:27:40.429Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx:553-555
Timestamp: 2026-02-03T18:27:40.429Z
Learning: In apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx, the menu buttons (e.g., Edit with PencilSquareIcon) in the TableCellMenu are intentionally icon-only with no text labels as a compact UI pattern. This is a deliberate design choice for this route; preserve the icon-only behavior for consistency in this file.

Applied to files:

  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
📚 Learning: 2026-02-11T16:37:32.429Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3019
File: apps/webapp/app/components/primitives/charts/Card.tsx:26-30
Timestamp: 2026-02-11T16:37:32.429Z
Learning: In projects using react-grid-layout, avoid relying on drag-handle class to imply draggability. Ensure drag-handle elements only affect dragging when the parent grid item is configured draggable in the layout; conditionally apply cursor styles based on the draggable prop. This improves correctness and accessibility.

Applied to files:

  • apps/webapp/app/routes/admin.back-office._index.tsx
  • apps/webapp/app/routes/admin.orgs.tsx
  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
📚 Learning: 2026-04-02T19:18:26.255Z
Learnt from: samejr
Repo: triggerdotdev/trigger.dev PR: 3319
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx:179-189
Timestamp: 2026-04-02T19:18:26.255Z
Learning: In this repo’s route components that render the Inspector `ResizablePanelGroup` panels, it’s acceptable to pass `collapsed={!isShowingInspector}` together with a no-op `onCollapseChange={() => {}}` when panel visibility is intentionally controlled only by route parameters (e.g., `*Param` search/route params) rather than user drag/collapse interactions. Do not flag an empty/no-op `onCollapseChange` as “missing wiring” in these cases; only flag it when collapse state is expected to change based on user interaction.

Applied to files:

  • apps/webapp/app/routes/account.tokens/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
📚 Learning: 2026-03-26T09:02:07.973Z
Learnt from: myftija
Repo: triggerdotdev/trigger.dev PR: 3274
File: apps/webapp/app/services/runsReplicationService.server.ts:922-924
Timestamp: 2026-03-26T09:02:07.973Z
Learning: When parsing Trigger.dev task run annotations in server-side services, keep `TaskRun.annotations` strictly conforming to the `RunAnnotations` schema from `trigger.dev/core/v3`. If the code already uses `RunAnnotations.safeParse` (e.g., in a `#parseAnnotations` helper), treat that as intentional/necessary for atomic, schema-accurate annotation handling. Do not recommend relaxing the annotation payload schema or using a permissive “passthrough” parse path, since the annotations are expected to be written atomically in one operation and should not contain partial/legacy payloads that would require a looser parser.

Applied to files:

  • apps/webapp/app/services/personalAccessToken.server.ts
📚 Learning: 2026-05-01T15:45:05.096Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3499
File: internal-packages/rbac/src/fallback.ts:34-107
Timestamp: 2026-05-01T15:45:05.096Z
Learning: When reviewing triggerdotdev/trigger.dev RBAC auth code, do not treat missing Personal Access Token (PAT) handling inside `authenticateBearer` as a bug. `authenticateBearer` is intentionally scoped to runtime environment API keys and Public JWTs only; PAT auth is handled via the separate PAT route builder (e.g., `createLoaderPATApiRoute`) which calls `authenticateApiRequestWithPersonalAccessToken` directly. Ensure that reviewers compare auth behavior against these distinct architectural paths (OSS fallback and cloud plugin) before flagging an issue.

Applied to files:

  • internal-packages/rbac/src/fallback.ts
🪛 LanguageTool
.changeset/rbac-assignable-role-ids.md

[style] ~5-~5: This sentence already contains “right now now”. Therefore, this adverb might be redundant. Consider removing it.
Context: ...sable role-dropdown options that aren't currently assignable. The default fallback return...

(CURRENTLY_AT_THE_MOMENT)

.changeset/rbac-mutation-result-types.md

[grammar] ~5-~5: Ensure spelling is correct
Context: ...} | { ok: false; error: string }). The webapp surfaces the error` strings directly t...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

apps/webapp/test/README.md

[grammar] ~1-~1: Ensure spelling is correct
Context: # Webapp tests Three suites live in this direct...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~13-~13: Ensure spelling is correct
Context: ...is wired up. Each file spins up its own webapp + Postgres + Redis container in `before...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (7)
apps/webapp/app/routes/admin.back-office._index.tsx (1)

7-12: Nice migration to centralized auth gating.

Using dashboardLoader with authorization.requireSuper here keeps this route aligned with the unified auth/authorization flow while preserving the loader response contract.

internal-packages/rbac/vitest.config.ts (1)

3-10: Looks good for a package-level Vitest config.

The scoped include pattern, test isolation, globals setting, and explicit timeout are all sensible here.

Based on learnings: in build/tool config files like vitest.config.ts, using export default defineConfig(...) is the expected pattern.

apps/webapp/app/routes/admin.orgs.tsx (2)

3-3: Typed loader data integration looks solid.
Using typedjson with useTypedLoaderData<typeof loader>() keeps the loader/component contract strongly typed and clear.


32-43: Good auth-path consolidation in the loader.
dashboardLoader({ authorization: { requireSuper: true } }, ...) cleanly enforces authn/authz before data access and matches the migration goal for admin routes.

apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts (1)

89-100: Nice auth/resource alignment here.

This now selects exactly the fields the RBAC resource mapping and stream handler consume, and the environment-scoped lookup keeps the loader path aligned with the consolidated auth boundary.

Also applies to: 105-115

internal-packages/testcontainers/src/utils.ts (1)

10-14: Nice change: type-only Vitest import keeps module import-safe

This keeps TaskContext typing without eagerly initializing Vitest at module load.

apps/webapp/app/routes/api.v2.tasks.batch.ts (1)

38-48: Nice fix: everyResource() restores all-items authorization for mixed-task batches.

This closes the earlier any-match gap cleanly and keeps the auth boundary aligned with the batch semantics.

Comment on lines +248 to +263
// If the invite carried an explicit RBAC role. Errors are logged, not fatal.
if (result.rbacRoleId) {
const roleResult = await rbac.setUserRole({
userId: user.id,
organizationId: result.organization.id,
roleId: result.rbacRoleId,
});
if (!roleResult.ok) {
logger.error("acceptInvite: skipped RBAC role assignment", {
organizationId: result.organization.id,
userId: user.id,
rbacRoleId: result.rbacRoleId,
reason: roleResult.error,
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t silently complete invite acceptance when RBAC role assignment fails.

At this point the org membership is already committed, so a real setUserRole() failure leaves the user in a partially provisioned state: they joined the org, but they may not have the role that migrated RBAC-gated routes expect. Please make non-fallback failures recoverable before returning success here (for example: compensating cleanup, retry/outbox, or surfacing a failure that prevents the invite from being consumed).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/models/member.server.ts` around lines 248 - 263, In
acceptInvite, don’t treat rbac.setUserRole failures as non-fatal: when
rbac.setUserRole returns !ok (the block checking result.rbacRoleId currently
only logs via logger.error), either perform compensating cleanup (remove the
just-created org membership / undo invite consumption using the membership or
invite service) or surface the failure to the caller by throwing an error so the
invite is not consumed; reference the existing acceptInvite flow and the
rbac.setUserRole call and ensure the invite consumption/membership creation is
rolled back or the error is propagated (alternatively implement a retry/outbox
for setUserRole) instead of silently continuing.

Comment on lines +50 to +63
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
authorization: { action: "read", resource: { type: "members" } },
},
async ({ params }) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

builder="$(fd -p 'dashboardBuilder.ts' apps/webapp/app/services | head -n1)"
echo "Inspecting: $builder"
nl -ba "$builder" | sed -n '1,260p' | sed -n '/dashboardLoader/,/dashboardAction/p'

Repository: triggerdotdev/trigger.dev

Length of output: 201


🏁 Script executed:

cat -n apps/webapp/app/services/routeBuilders/dashboardBuilder.ts | head -300

Repository: triggerdotdev/trigger.dev

Length of output: 6025


🏁 Script executed:

cat -n apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts | head -400

Repository: triggerdotdev/trigger.dev

Length of output: 3936


🏁 Script executed:

rg -A 10 "context: async" apps/webapp/app/routes --type tsx | grep -A 10 "organizationSlug\|projectSlug"

Repository: triggerdotdev/trigger.dev

Length of output: 97


🏁 Script executed:

rg -A 10 "context: async" apps/webapp/app/routes -t ts | head -100

Repository: triggerdotdev/trigger.dev

Length of output: 3933


Move the missing-org 404 check into context to ensure it runs before authorization.

The execution order in dashboardLoader is: context → authenticateSession → authorization check → handler. When context returns {} on a missing org slug, the authorization check (lines 51-52: { action: "read", resource: { type: "members" } }) runs with no org context. This likely results in a 403 redirect before the handler's 404 throw (lines 60-63) ever executes, turning an invalid slug into a permission error instead of "Not Found".

The same pattern exists in the Team settings loader route. Fix by throwing the 404 from context when resolveOrgIdFromSlug returns falsy, rather than returning {}.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.roles/route.tsx
around lines 50 - 63, The loader currently returns an empty context when
resolveOrgIdFromSlug(params.organizationSlug) is falsy, allowing the
authorization check to run without org context; instead, in the dashboardLoader
call change the context function to throw a 404 (e.g. throw new Response("Not
Found", { status: 404 })) when resolveOrgIdFromSlug returns falsy so the
not-found check runs before authenticate/authorization; update the context
implementation used in route loader (the context callback inside dashboardLoader
that calls resolveOrgIdFromSlug) to throw the Response rather than returning {}
so authorization sees only valid orgs.

Comment on lines +189 to +197
if (formType === "purchase-seats") {
const org = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});

if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
if (!org) {
return json({ ok: false, error: "Organization not found" } as const);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return a 404 for the missing-organization case here.

This branch currently serializes "Organization not found" with a 200 response, unlike the set-role path above. That makes a missing slug look like a successful fetcher submission unless the caller inspects the body.

Suggested fix
       if (!org) {
-        return json({ ok: false, error: "Organization not found" } as const);
+        return json({ ok: false, error: "Organization not found" } as const, {
+          status: 404,
+        });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.team/route.tsx
around lines 189 - 197, The "purchase-seats" branch currently returns json({ ok:
false, error: "Organization not found" }) with a 200; instead make the
missing-organization case return a 404. In the formType === "purchase-seats"
block (where you call $replica.organization.findFirst with organizationSlug),
change the early return to either throw new Response(JSON.stringify({ ok: false,
error: "Organization not found" }), { status: 404 }) or return json({ ok: false,
error: "Organization not found" }, { status: 404 }) so the HTTP status matches
the error condition.

Comment on lines +204 to +226
it("cross-env: env A's JWT cannot complete env B's waitpoint: not 200", async () => {
const server = getTestServer();
const a = await seedTestEnvironment(server.prisma);
const b = await seedEnvAndWaitpoint();
const jwt = await generateJWT({
secretKey: a.apiKey,
payload: {
pub: true,
sub: a.environment.id,
scopes: [`write:waitpoints:${b.waitpoint.friendlyId}`],
},
expirationTime: "15m",
});
// The JWT is signed by env A and its sub claim says env A. The
// route resolves env from the sub claim and the waitpoint is
// env B's, so the lookup misses. The exact code depends on
// whether auth or the resource lookup fires first — both
// outcomes are correct, just NOT 200.
const res = await completeRequest(pathFor(b.waitpoint.friendlyId), {
Authorization: `Bearer ${jwt}`,
});
expect(res.status).not.toBe(200);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten these negative assertions to expected failure statuses.

Each expect(res.status).not.toBe(200) also passes on 500s, so these tests can stop proving auth isolation/no-access if the route starts crashing. Please constrain them to the specific non-success statuses each path is allowed to return instead of only asserting “not 200”.

Also applies to: 364-384, 1535-1552, 1724-1741, 1895-1928

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/test/auth-api.e2e.full.test.ts` around lines 204 - 226, The tests
use loose negative assertions like expect(res.status).not.toBe(200) which also
pass on 500s; update each affected test (e.g., the "cross-env: env A's JWT
cannot complete env B's waitpoint: not 200" case and the other occurrences
noted) to assert the explicit allowed failure statuses instead of "not 200".
Replace expect(res.status).not.toBe(200) with a precise check such as
expect([401, 403, 404]).toContain(res.status) (or the exact set your app may
legitimately return for auth/lookup failures) and do the same in other tests
that currently use the negative assertion; reference the helper functions
generateJWT, seedTestEnvironment, seedEnvAndWaitpoint, completeRequest, and
pathFor to locate the tests to change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant